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 7f9fe28..0e4a5d0 100644
--- a/docs/api/reactivity-core.md
+++ b/docs/api/reactivity-core.md
@@ -2,6 +2,30 @@
+## root()
+
+Creates and runs a function in a new reactive scope.
+
+- **Type**
+
+ ```lua
+ function root(fn: (destroy: () -> ()) -> T...): T...
+ ```
+
+- **Details**
+
+ Creates a new root reactive scope, where creation and derivations of sources
+ can be tracked and properly disposed of.
+
+ Returns the result of the given function.
+
+ A function to destroy the root is passed into the callback, 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.
@@ -14,10 +38,10 @@ Creates a new source with the given value.
- **Details**
- Calling the returned source with no arguments will return its stored value,
- calling with arguments will set a new value.
+ Calling the returned source with no argument will return its stored value,
+ calling with an argument will set a new value.
- Reading from the source from within any reactive scope will cause changes
+ Reading from the source from within a reactive scope will cause changes
to that source to be tracked and anything depending on it to update.
- **Example**
@@ -30,44 +54,39 @@ Creates a new source with the given value.
count(count() + 1) -- 1
```
-## watch()
+## effect()
-Runs a callback on source update.
+Runs a side-effect on source update.
- **Type**
```lua
- function watch(source: () -> ()): Unwatch
-
- type Unwatch = () -> ()
+ 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 watcher immediately.
-
::: warning
- `source()` cannot yield.
+ `callback()` cannot yield.
:::
- **Example**
```lua
- local state = source(1)
+ local num = source(1)
- watch(function()
- print(state())
+ effect(function()
+ print(num())
end)
-- prints 1
- state(state() + 1)
+ num(num() + 1)
-- prints 2
```
@@ -110,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
new file mode 100644
index 0000000..58e2200
--- /dev/null
+++ 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 4597364..22adba5 100644
--- a/docs/api/reactivity-utility.md
+++ b/docs/api/reactivity-utility.md
@@ -2,7 +2,7 @@
## cleanup()
-Runs a callback anytime a function scope is re-ran.
+Runs a callback anytime a reactive scope is re-ran.
- **Type**
@@ -10,25 +10,12 @@ Runs a callback anytime a function scope is re-ran.
function cleanup(callback: () -> ())
```
-- **Details**
-
- The primary purpose of this function is to provide a means of cleaning up
- side effects caused by source updates and `watch()` updates.
-
- The stack is inspected to find the function that calls `cleanup()`. The
- callback passed is called anytime the caller is re-ran, and when the caller
- finally garbage collects.
-
- ::: warning
- Only one `cleanup()` call is allowed per function scope.
- :::
-
- **Example**
```lua
local data = source(1)
- watch(function()
+ effect(function()
local label = create "TextLabel" { Text = data() }
cleanup(function()
@@ -53,7 +40,7 @@ Runs a callback anytime a function scope is re-ran.
## untrack()
-Gets the value of a source without reactively tracking it.
+Runs a given function where any sources read will not track its reactive scope.
- **Type**
@@ -82,3 +69,5 @@ Gets the value of a source without reactively tracking it.
a(1)
print(sum()) -- 2
```
+
+--------------------------------------------------------------------------------
diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md
index c0f1e7e..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 watchers 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 watchers, 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/crash-course/6-table-source.md b/docs/tut/control-flow/indexes.md
similarity index 99%
rename from docs/tut/crash-course/6-table-source.md
rename to docs/tut/control-flow/indexes.md
index 04c6dee..70864e7 100644
--- a/docs/tut/crash-course/6-table-source.md
+++ b/docs/tut/control-flow/indexes.md
@@ -1,4 +1,4 @@
-# Table Source
+# Control Flow
Vide has specific functions for dealing with sources that store a table value.
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 0197384..7b00dac 100644
--- a/docs/tut/crash-course/1-introduction.md
+++ b/docs/tut/crash-course/1-introduction.md
@@ -3,7 +3,7 @@
This is a brief tutorial designed to give you a quick run through the usage of
Vide.
-Vide is largely inspired by other UI libraries such as Solid and Fusion.
+Vide is heavily inspired by [Solid](https://www.solidjs.com/).
## Why Vide?
@@ -19,7 +19,29 @@ 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.
-- A powerful reactive system that does not interfere with the lifetime of
- instances.
+- Flexibility with integrating other libraries and allowing users to use their
+ own patterns.
+- Independence from instance lifetimes.
+- 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
+
+The entry point for all Vide apps is the `mount()` function. This function
+sets up Vide's reactivity system. It takes and calls a function that should
+create your entire app, and will apply its result to a target.
+
+In Vide, your app should be composed of functions, each function creates a
+specific part of your app, and can be reused if needed. These functions are
+called *components*.
+
+```lua
+
+local function App()
+ return create "ScreenGui" {
+ create "TextLabel" { Text = "hi" }
+ }
+end
+
+mount(App, game.StarterGui)
+```
diff --git a/docs/tut/crash-course/8-actions.md b/docs/tut/crash-course/10-actions.md
similarity index 100%
rename from docs/tut/crash-course/8-actions.md
rename to docs/tut/crash-course/10-actions.md
diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md
index e180910..deb1683 100644
--- a/docs/tut/crash-course/2-creation.md
+++ b/docs/tut/crash-course/2-creation.md
@@ -1,62 +1,54 @@
-# Creating UI Elements
+# Creating UI
-Instances are created using [`create()`](../../api/creation.md#create).
-
-```lua
-local vide = require(path_to_vide)
-local create = vide.create
-```
+Instances are created using `create()`.
`create()` returns a constructor for a class which then takes a table of
properties to assign when creating a new instance for that class.
Luau allows us to omit parentheses `()` when calling functions with string or
-table literals for brevity.
+table literals which Vide takes advantage of for brevity.
```lua
-local frame = create "Frame" {
- Name = "Background",
- Position = UDim2.fromScale(0.5, 0.5)
-}
-```
+local vide = require(vide)
+local mount = vide.mount
+local create = vide.create
-String keys are treated as properties and integer keys are treated as child
-instances.
+local function App()
+ return create "ScreenGui" {
+ create "Frame" {
+ AnchorPoint = Vector2.new(0.5, 0.5),
+ Position = UDim2.fromScale(0.5, 0.5),
+ Size = UDim2.fromScale(0.4, 0.7),
-```lua
-create "ScreenGui" {
- Parent = game.StarterGui,
+ create "TextLabel" {
+ Text = "hi"
+ },
- create "Frame" {
- AnchorPoint = Vector2.new(0.5, 0.5),
- Position = UDim2.fromScale(0.5, 0.5),
- Size = UDim2.fromScale(0.4, 0.7),
+ create "TextLabel" {
+ Text = "bye"
+ },
- create "TextLabel" {
- Text = "hi"
- },
+ create "TextButton" {
+ Text = "click me",
- create"TextLabel" {
- Text = "bye"
+ Activated = function()
+ print "clicked!"
+ end
+ }
}
}
-}
+end
+
+mount(App, game.StarterGui)
```
-To connect to an event, just assign the event property a function.
+Assign a value to a string key to set a property, and assign a value to a
+number key to set a child. Events can be connected to by assigning a function
+to a string key.
-All event arguments are passed into the function.
-
-```lua
-create "TextButton" {
- Activated = function()
- print "clicked!"
- end
-}
-```
-
-You can also use a form of aggregate initialization to create datatypes instead
-of explicitly typing out the class name and constructor.
+You can also use a shorthand to create datatypes instead of explicitly typing
+out the class name and constructor. The table will be unpacked into the `.new()`
+constructor of the property's type.
```lua
create "Frame" {
@@ -64,7 +56,3 @@ create "Frame" {
UDim2 = { 0.5, 0, 0.5, 0 }
}
```
-
-When a property is assigned a table, Vide will inspect the type of the property
-being assigned to, and call that type's default `new()` constructor with the
-unpacked values from the assigned table.
diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md
index ef97920..712ab9a 100644
--- a/docs/tut/crash-course/3-components.md
+++ b/docs/tut/crash-course/3-components.md
@@ -5,7 +5,10 @@ Components are custom-made reusable pieces of UI made from other pieces of UI.
By using components you can make your application more modular and better
organized.
-```lua
+```lua [Button.luau]
+local vide = require(vide)
+local create = vide.create
+
local function Button(props: {
Position: UDim2,
Text: string,
@@ -20,29 +23,44 @@ local function Button(props: {
Activated = props.Activated
}
end
+
+return Button
```
-Above is a simple example of a button component with its background color set to
-a dark grey and with a fixed size.
+```lua [App.luau]
+local vide = require(vide)
+local mount = vide.mount
+local create = vide.create
+
+local Button = require(Button)
+
+local function App()
+ return create "ScreenGui" {
+ Button {
+ Position = UDim2.fromOffset(200, 200),
+ Text = "click me!",
+
+ Activated = function()
+ print "clicked"
+ end
+ }
+ }
+end
+
+mount(App, game.StarterGui)
+```
+
+Above is a simple example of a button component with a set color and size,
+being reused across files.
A single parameter `props` is used to pass properties to the component.
-Creating instances of this button component is as simple as doing the below:
-
-```lua
-local button = Button {
- Position = UDim2.new(),
- Text = "Click me!",
-
- Activated = function()
- print "clicked"
- end
-}
-```
Components allow you to *encapsulate* behavior. You can only modify the
component in ways that you allow in the component.
This also promotes code reusability. Anytime you want a new button all you do
is call `Button {}` instead of creating and setting every property each time.
+When changing the button in future, any changes to the button file will be
+reflected anywhere the button is used throughout your app.
This can be extended to much more complicated UI.
diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md
index 20ff4f1..cf7ceb2 100644
--- a/docs/tut/crash-course/4-source.md
+++ b/docs/tut/crash-course/4-source.md
@@ -4,33 +4,18 @@
core of reactivity in Vide, as updates to a source can automatically update
properties or other sources depending on that source.
-A source in Vide can be created using
-[`source()`](../../api/reactivity-core.md#source).
+A source in Vide can be created using `source()`.
```lua
+local vide = require(vide)
local source = vide.source
-local count = source(0)
-```
-
-The value passed to `source()` is the initial value of the source.
-
-The value of a source can be set by calling it with an argument, and can be read
-by calling it with no arguments.
-
-```lua
-count(count() + 1) -- increment source by 1
-```
-
-Below is an example of a stateful counter component.
-
-```lua
-local function Counter(props: { Position: UDim2 })
+local function Counter()
local count = source(0)
return create "TextButton" {
- Position = props.Position,
- Size = UDim2.new(200, 50),
+ Position = UDim2.fromOffset(300, 300),
+ Size = UDim2.fromOffset(200, 50),
Text = count,
@@ -39,37 +24,28 @@ local function Counter(props: { Position: UDim2 })
end
}
end
+
+mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
```
-Each call of `Counter {}` will create a new counter element, each with their own
-independent count.
+The value passed to `source()` is the initial value of the source.
-Vide detects when you assign a function to a property. This is known
-as *binding* and doing so will cause the property to *automatically* update
-whenever a source in that function is updated, by rerunning the function and
-assigning its return value. You can only bind non-event
-properties, otherwise the function is connected as the event callback.
-
-This allows you as the programmer to not need to manually update GUI as the state
-of your program changes. You just define how the data maps to UI, and Vide's
-reactive system will surgically update any properties depending on sources that
-are changed.
-
-Since sources are just functions, you can also pass external sources to
-components like so:
+The value of a source can be set by calling it with an argument, and can be read
+by calling it with no arguments.
```lua
-local function Text(p: {
- Text: () -> string
-})
- return create "TextLabel" {
- Text = p.Text
- }
-end
-
-local text = source "hi"
-
-Text {
- Text = text
-}
+count(count() + 1) -- increment count by 1
```
+
+Each call of `Counter {}` will create a new counter, each maintaining their
+own count.
+
+When you assign a function to a non-event property, Vide will immediately run it
+and check what sources were read from. When updating those sources again after,
+this function will be re-ran and its return value applied to the property.
+This is known as *binding* properties.
+
+This allows you as the programmer to not need to manually update UI as the state
+of your program changes. You just define how the data maps to UI, and Vide's
+reactive system will automatically update any properties depending on sources
+that are updated.
diff --git a/docs/tut/crash-course/5-derived-source.md b/docs/tut/crash-course/5-derived-source.md
deleted file mode 100644
index a8b5b05..0000000
--- a/docs/tut/crash-course/5-derived-source.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# Derived Source
-
-You can create new sources from existing sources. This is known as *deriving
-sources*.
-
-A function that wraps a source effectively becomes a new source. If a source
-used inside a function is updated, the whole function can be re-ran to recompute
-its value.
-
-```lua
-local count = source(0)
-
-local function text()
- return "count: " .. count()
-end
-
-create "TextLabel" {
- Text = text
-}
-```
-
-Sometimes when using expensive computations to derive state, you only want to
-recalculate it once when a source state has changed
-
-If you wrap a source with a regular function, its value will be recomputed
-every time you call that function.
-[`derive()`](../../api/reactivity-core.md#derive) accepts a functions whose
-return value will be cached, so that subsequent calls of this derived source
-will return the same cached value until one of its input sources have changed.
-
-```lua
-local derive = vide.derive
-```
-
-```lua
-local count = source(0)
-
-local factorial = derive(function()
- local n = 1
- for i = 2, count() do
- n *= i
- end
- return n
-end)
-```
-
-This can improve performance in cases where a source is read from multiple times
-between recalculations, like in the example below:
-
-```lua
-create "TextLabel" {
- Text = function()
- return "factorial squared: " .. factorial() * factorial()
- end
-}
-
-count(3) -- displays "factorial squared: 36"
-count(4) -- displays "factorial squared: 576"
-```
-
-Vide knows what sources are being depended on by immediately running the
-callback when deriving or binding sources. If a source is in a function but is
-never referenced the first time it runs, Vide will not know to rerun the
-function if that source changes.
-
-An example to watch out for is when using sources within branches:
-
-```lua
-local condition = source(true)
-local count1 = source(0)
-local count2 = source(0)
-
-local text = function()
- if condition() then
- return "text: " .. count1()
- else
- return "text: " .. count2()
- end
-end
-```
-
-In the above case, only `count1` will be referenced, meaning `text` will not be
-aware of `count2` even if the condition is later set to false.
-
-All sources to be tracked must be referenced the first time the function runs.
-
-```lua
-local condition = source(true)
-local count1 = source(0)
-local count2 = source(0)
-
-local text = function()
- local c1 = count1()
- local c2 = count2()
-
- if condition() then
- return "text: " .. c1
- else
- return "text: " .. c2
- end
-end
-```
diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md
new file mode 100644
index 0000000..67c1f58
--- /dev/null
+++ b/docs/tut/crash-course/5-effect.md
@@ -0,0 +1,45 @@
+# Effect
+
+An effect is a function that is run anytime a source updates. They are called
+effects because they can produce side-effects when reacting to source changes.
+
+Effects are created using `effect()`.
+
+```lua
+local vide = require(vide)
+local source = vide.source
+local effect = vide.effect
+
+local function Counter()
+ local count = source(0)
+
+ effect(function()
+ print("count has updated to: " .. count())
+ end)
+
+ return create "TextButton" {
+ Position = UDim2.fromOffset(300, 300),
+ Size = UDim2.fromOffset(200, 50),
+
+ Text = count,
+
+ Activated = function()
+ count(count() + 1)
+ end
+ }
+end
+
+mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
+```
+
+This will print to the terminal anytime the count is changed.
+
+`effect()` creates an explicit side-effect. There are other side-effects in the
+above code sample. The setting of `Text = count` creates another side-effect;
+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/6-derived-source.md b/docs/tut/crash-course/6-derived-source.md
new file mode 100644
index 0000000..01fe1c2
--- /dev/null
+++ b/docs/tut/crash-course/6-derived-source.md
@@ -0,0 +1,76 @@
+# Derived Source
+
+You can create new sources from existing sources. This is known as *deriving
+sources*.
+
+A function that wraps a source effectively becomes a new source. If a source
+used inside a function is updated, the whole function can be re-ran to recompute
+its value.
+
+```lua
+local vide = require(vide)
+local source = vide.source
+
+local function Counter()
+ local count = source(0)
+
+ local function doubled()
+ return count() * 2
+ end
+
+ return create "TextButton" {
+ Position = UDim2.fromOffset(300, 300),
+ Size = UDim2.fromOffset(200, 50),
+
+ Text = doubled,
+
+ Activated = function()
+ count(count() + 1)
+ end
+ }
+end
+
+mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
+```
+
+Now the counter will increment in 2s each time it is clicked.
+
+Sometimes when using expensive computations to derive state, you only want to
+recalculate it once when a source state has changed. Although not needed in
+most cases, you can use `derive()` to create a new source that will cache its
+value, only recomputing when an input source has changed.
+
+```lua
+local vide = require(vide)
+local source = vide.source
+local derive = vide.derive
+
+local function Counter()
+ local count = source(0)
+
+ local factorial = derive(function()
+ local n = 1
+ for i = 2, count() do
+ n *= i
+ end
+ return n
+ end)
+
+ return create "TextButton" {
+ Position = UDim2.fromOffset(300, 300),
+ Size = UDim2.fromOffset(200, 50),
+
+ Text = function()
+ return factorial() + factorial() + factorial()
+ end,
+
+ Activated = function()
+ count(count() + 1)
+ end
+ }
+end
+```
+
+This can improve performance in cases where a source is read from multiple times
+between recalculations. In the above example, the factorial is only ever
+calculated once each time the count changes.
diff --git a/docs/tut/crash-course/7-cleanup.md b/docs/tut/crash-course/7-cleanup.md
new file mode 100644
index 0000000..3e5525d
--- /dev/null
+++ b/docs/tut/crash-course/7-cleanup.md
@@ -0,0 +1,40 @@
+# Cleanup
+
+Sometimes you may need to do some cleanup when destroying a component or after
+a side-effect from a source update. Vide provides a function `cleanup()` which
+is used to register a cleanup callback for the next time the reactive scope
+it is called in re-runs.
+
+```lua
+local vide = require(vide)
+local source = vide.source
+local cleanup = vide.cleanup
+
+local function Timer()
+ local count = source(0)
+
+ local con = game:GetService("RunService").Heartbeat:Connect(function(dt)
+ count(count() + dt)
+ end)
+
+ cleanup(function()
+ con:Disconnect()
+ end)
+
+ return create "TextButton" {
+ Position = UDim2.fromOffset(300, 300),
+ Size = UDim2.fromOffset(200, 50),
+
+ Text = function()
+ return "seconds: " .. count()
+ end,
+ }
+end
+
+mount(function() return create "ScreenGui" { Timer {} } end, game.StarterGui)
+```
+
+In the above example, this allows us to disconnect the heartbeat connection
+when the timer component is destroyed, whether that is from unmounting the app
+or if it is dynamically created by a control-flow function, which will be
+covered next.
diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/8-control-flow.md
new file mode 100644
index 0000000..f590145
--- /dev/null
+++ b/docs/tut/crash-course/8-control-flow.md
@@ -0,0 +1,95 @@
+# Control Flow
+
+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, `indexes()` can autmatically run a transform function for each
+index and value, generating a UI element.
+
+```lua
+local todoList = {
+ "Finish the crash course",
+ "Star vide's GitHub"
+}
+
+local elements = indexes(todoList, function(todo, i)
+ return create "TextLabel" {
+ Text = function()
+ return i .. ": " .. todo()
+ end,
+
+ LayoutOrder = i
+ }
+end)
+
+mount(function()
+ return create "ScreenGui" {
+ create "UIListLayout" {}, elements
+ }
+end, game.StarterGui)
+```
+
+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.
+
+When the value at an index is changed, the function is not reran. Instead, the
+given source is updated instead.
+
+`indexes()` is said to map each *index* in a table to a UI element, each index
+has a single corresponding element.
+
+An element is only destroyed if the value of an index is set to `nil`.
diff --git a/docs/tut/crash-course/7-nested-properties.md b/docs/tut/crash-course/9-property-nesting.md
similarity index 100%
rename from docs/tut/crash-course/7-nested-properties.md
rename to docs/tut/crash-course/9-property-nesting.md
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 88f4a22..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
@@ -17,7 +17,7 @@ local function action(callback: (Instance) -> (), priority: number?): Action
setmetatable(t :: any, ActionMT)
- return t
+ return table.freeze(t)
end
return function()
diff --git a/src/apply.luau b/src/apply.luau
index 9bc5d25..d0bccf7 100644
--- a/src/apply.luau
+++ b/src/apply.luau
@@ -10,11 +10,16 @@ local _, is_action = require(script.Parent.action)()
local graph = require(script.Parent.graph)
type Node = graph.Node
+type Array = { V }
+type Map = { [K]: V }
+
-- buffer of event -> callback to connect after properties are set
-local event_buffer: { [string]: () -> () } = {}
+local event_buffer = {} :: Map ()>
-- buffer of priority -> callback to run after events are connected
-local action_buffers = {} :: { { (Instance) -> () } }
+local action_buffers = {} :: Map ()>>
+
+-- lazily create buffers on nil index
setmetatable(action_buffers :: any, {
__index = function(_, i: number)
action_buffers[i] = {}
@@ -22,8 +27,9 @@ setmetatable(action_buffers :: any, {
end
})
--- cache used in strict mode to detect duplicate property sets at same nesting levels
-local nested_debug_cache: { [number]: { [string]: true } } = {}
+-- cache in strict mode to detect duplicate property set at same nesting level
+local nested_debug_cache = {} :: Map>
+
setmetatable(nested_debug_cache :: any, {
__index = function(_, i: number)
nested_debug_cache[i] = {}
@@ -31,28 +37,30 @@ setmetatable(nested_debug_cache :: any, {
end
})
--- a stack used in place of a recursive function to process nesting layers one at a time
--- enforces the behavior of deeper-nested properties taking precedence of lesser-nested ones
--- each nested table occupies two indexes, reference to table itself and the depth number
--- e.g. props = { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
+-- use stack instead of recursive function to process nested layers one at time
+-- deeper-nested properties take precedence over shallower-nested ones
+-- each nested layer occupies two indexes: 1. table ref 2. nested depth
+-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
local nested_stack = {} :: { {} | number }
-- todo: solution without manual updating of this table
--- map of datatype names to class default constructor for aggregate initialization
+-- map of datatype names to class default constructor for aggregate init
local aggregates = {}
for i, v in next, {
- Vector2 = Vector2,
- UDim2 = UDim2,
+ CFrame = CFrame,
+ Color3 = Color3,
UDim = UDim,
- Rect = Rect,
- Color3 = Color3
+ UDim2 = UDim2,
+ Vector2 = Vector2,
+ Vector3 = Vector3,
+ Rect = Rect
} do
aggregates[i] = v.new
end
-- processes a potentially nested table of values to assign to an instance
-local function process_nested(instance: Instance, properties: { [unknown]: unknown })
+local function process_props(instance: Instance, properties: Map)
local strict = flags.strict
table.clear(nested_stack)
@@ -73,27 +81,27 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno
if type(value) == "table" then -- attempt aggregate init
local ctor = aggregates[typeof((instance :: any)[property])]
if ctor == nil then
- throw(`cannot aggregate construct type {typeof(value)} for property {property}`)
+ throw(`cannot aggregate type {typeof(value)} for property {property}`)
end
(instance :: any)[property] = ctor(unpack(value :: {}))
elseif type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> () -- add event to buffer
else
- bind.property(instance, property, value :: () -> ()) -- bind source
+ bind.property(instance, property, value :: () -> ()) -- bind property
end
else
(instance :: any)[property] = value -- set property
end
elseif type(property) == "number" then
if type(value) == "function" then
- bind.children(instance, value :: () -> { Instance }) -- bind children
+ bind.children(instance, value :: () -> Instance | Array) -- bind children
elseif type(value) == "table" then
if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer
else
- table.insert(nested_stack, depth + 1) -- push table to stack for later processing
table.insert(nested_stack, value :: {})
+ table.insert(nested_stack, depth + 1) -- push table to stack for later processing
end
else
(value :: Instance).Parent = instance -- parent child
@@ -102,8 +110,8 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno
end
-- pop next nested table off stack
- properties = table.remove(nested_stack) :: {}
depth = table.remove(nested_stack) :: number
+ properties = table.remove(nested_stack) :: {}
until not properties
end
@@ -121,14 +129,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown
end
-- process all properties for immediate setting or buffering
- process_nested(instance, properties)
+ process_props(instance, properties)
-- connect buffered events
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
- -- run buffered actions respecting their priorities
+ -- run buffered actions
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback(instance)
@@ -138,7 +146,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown
-- finally set parent if any
if parent then
if type(parent) == "function" then
- error("cannot set parent to state")
+ bind.parent(instance, parent :: () -> Instance)
else
instance.Parent = parent :: Instance
end
diff --git a/src/bind.luau b/src/bind.luau
index 7a08292..bcd0701 100644
--- a/src/bind.luau
+++ b/src/bind.luau
@@ -1,152 +1,130 @@
if not game then script = require "test/relative-string" end
-local warn = game and warn or print :: never
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
-local set_effect = graph.set_effect
-local capture = graph.capture
+local create_node = graph.create_node
+local get_scope = graph.get_scope
+local evaluate_node = graph.evaluate_node
+local set_owner = graph.set_owner
---[[
-
-Roblox instances in Luau are referenced using a kind of userdata proxy,
-this proxy can be garbage collected independently from the actual instance, even
-if the instance is still parented. Since reactive bindings allow the garbage
-collection of instances, this proxy can can garbage collected while the instance
-is still parented, causing the binding to be lost and no longer update the
-instance on changes.
-
-Vide's solution to this is to hold the proxy in memory as long as the instance
-is parented to the datamodel by using `GetPropertyChanged("Parent")` to add or
-remove the proxy from a table whose sole purpose is to strongly reference
-proxies.
-
-todo: investigate behavior in case B is parented to A, and A has no parent or reference, and B has a binding.
-
-]]
-
--- holds parented instance proxies in memory
-local hold: { Instance? } = {}
-
--- weakly references instances with properties bound
-local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any
-
--- unique binding id
-local bind_count = 0
-
--- 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 bind(instance: Instance, property: string, setter: (Instance) -> ())
+function create_binding(updater: (T) -> T, binding: T)
if flags.strict then
- -- wrap setter in function with stack inspection for better error msgs
- local fn = setter
- local bind_trace = traceback(0)
- setter = function(instance)
- local ok, err: string? = xpcall(fn, function(err: string)
- return err .. "\nsource updated at: " .. traceback(2)
- end, instance)
- if not ok then warn(`error occured updating {property}: {err}bound at: {bind_trace}`) end
+ -- track bind creation trace
+ local fn = updater
+ local bind_trace = debug.traceback(nil, trace()-1)
+ updater = function(...)
+ local ok, result = xpcall(fn, function(err: string)
+ return err
+ end, ...)
+
+ if not ok then
+ local btype =
+ if (binding :: any).property then (binding :: any).property
+ elseif (binding :: any).parent then "Parent"
+ else "children"
+ error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0)
+ end
+
+ return result
end
end
- -- run setter to capture any nodes being depended on
- local nodes = (capture(setter :: () -> unknown, instance))
-
- -- register the setter as a side-effect of each node
- for _, node in next, nodes do
- set_effect(node, setter, instance)
- end
-
- -- get binding id
- bind_count += 1
- local bind_id = bind_count
- -- store reference of instance proxy without preventing gc
- weak[bind_id] = instance
+ local owner = get_scope()
+ if not owner then
+ throw("cannot bind property in non-reactive scope")
+ end; assert(owner)
+
+ local node = create_node(binding, updater)
- local function ref()
- local _ = setter -- prevent gc of nodes being depended on
- local instance = weak[bind_id] :: Instance
+ set_owner(node, owner)
+ evaluate_node(node)
+end
- -- keep proxy in memory if instance is still parented
- hold[bind_id] = instance.Parent and instance or nil
+type PropertyBinding = {
+ instance: Instance,
+ property: string,
+ source: () -> unknown
+}
+
+local function update_property(p: PropertyBinding)
+ (p.instance :: any)[p.property] = p.source()
+ return p
+end
+
+type ParentBinding = {
+ instance: Instance,
+ parent: () -> Instance
+}
+
+local function update_parent(p: ParentBinding)
+ p.instance.Parent = p.parent()
+ return p
+end
+
+type ChildrenBinding = {
+ instance: Instance,
+ cur_children_set: { [Instance]: true },
+ new_children_set: { [Instance]: true },
+ children: () -> Instance | { Instance }
+}
+
+local function update_children(p: ChildrenBinding)
+ local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update
+ local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update
+
+ local new_children = p.children() -- all (and only) children that should be parented after this update
+
+ if type(new_children) ~= "table" then
+ new_children = { new_children }
end
- ref()
- instance:GetPropertyChangedSignal("Parent"):Connect(ref)
-end
-
-local function bind_property(instance: Instance, property: string, fn: () -> unknown)
- bind(instance, property, function(instance_weak: any)
- instance_weak[property] = fn()
- end)
-end
-
-local function bind_parent(instance: Instance, fn: () -> Instance?)
- instance.Destroying:Connect(function()
- instance = nil :: any -- allow gc when destroyed
- end)
-
- bind(instance, "Parent", function(instance)
- local _ = instance -- state will strongly reference instance when parent is bound
- instance.Parent = fn()
- end)
-end
-
-local function bind_children(parent: Instance, fn: () -> { Instance })
- local current_child_set: { [Instance]: true } = {} -- cache of all children parented before update
- local new_child_set: { [Instance]: true } = {} -- cache of all children parented after update
-
- bind(parent, "Children", function(parent_weak)
- local new_childs = fn() -- all (and only) children that should be parented after this update
- if new_childs and type(new_childs) ~= "table" then
- throw(`Cannot parent instance of type { type(new_childs) } `)
- end
-
- if new_childs then
- for _, child in next, new_childs do
- new_child_set[child] = true -- record child set from this update
- if not current_child_set[child] then
- child.Parent = parent_weak -- if child wasn't already parented then parent it
- else
- current_child_set[child] = nil -- remove child from cache if it was already in cache
- end
+ if new_children then
+ for _, child in next, new_children :: { Instance } do
+ new_child_set[child] = true -- record child set from this update
+ if not cur_children_set[child] then
+ child.Parent = p.instance -- if child wasn't already parented then parent it
+ else
+ cur_children_set[child] = nil -- remove child from cache if it was already in cache
end
end
+ end
- for child in next, current_child_set do
- child.Parent = nil -- unparent all children that weren't in the new children set
- end
+ for child in next, cur_children_set do
+ child.Parent = nil -- unparent all children that weren't in the new children set
+ end
- table.clear(current_child_set) -- clear cache, preserve capacity
- current_child_set, new_child_set = new_child_set, current_child_set
- end)
+ table.clear(cur_children_set) -- clear cache, preserve capacity
+ p.cur_children_set, p.new_children_set = new_child_set, cur_children_set
+
+ return p
end
return {
- property = bind_property,
- parent = bind_parent,
- children = bind_children,
+ property = function(instance, property, source)
+ return create_binding(update_property, {
+ instance = instance,
+ property = property,
+ source = source
+ })
+ end,
+
+ parent = function(instance, parent)
+ return create_binding(update_parent, {
+ instance = instance,
+ parent = parent
+ })
+ end,
+
+ children = function(instance, children)
+ return create_binding(update_children, {
+ instance = instance,
+ cur_children_set = {},
+ new_children_set = {},
+ children = children
+ })
+ end
}
diff --git a/src/changed.luau b/src/changed.luau
new file mode 100644
index 0000000..5d439c6
--- /dev/null
+++ b/src/changed.luau
@@ -0,0 +1,18 @@
+if not game then script = require "test/relative-string" end
+
+local action = require(script.Parent.action)()
+local cleanup = require(script.Parent.cleanup)
+
+local function changed(property: string, callback: (T) -> ())
+ return action(function(instance)
+ local con = instance:GetPropertyChangedSignal(property):Connect(function()
+ callback((instance :: any)[property])
+ end)
+
+ cleanup(function()
+ con:Disconnect()
+ end)
+ end)
+end
+
+return changed
diff --git a/src/cleanup.luau b/src/cleanup.luau
index d3da68a..05a516f 100644
--- a/src/cleanup.luau
+++ b/src/cleanup.luau
@@ -1,129 +1,18 @@
if not game then script = require "test/relative-string" end
-local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
+local graph = require(script.Parent.graph)
+local get_scope = graph.get_scope
+local add_cleanup = graph.add_cleanup
---[[
-
-Cleanups associate a callback with an arbitrary value with an unknown lifetime.
-Anytime a new callback is registered with a value that already has one registered,
-the registered callback is ran and then replaced with the new one.
-
-When the value is eventually garbage collected, Vide checks for callbacks
-without an associated value, which it will then run and clear, recycling its
-cleanup id.
-
-By default the arbitrary value is the function object that calls `cleanup()`.
-There are exceptions such as with `indexes()` and `values()` where the
-arbitrary value is manually set to be the new source created instead of the
-caller, as the same caller can be used to create multiple new objects.
-
-todo: remove need for ref to id maps?
-
-]]
-
--- maps a ref to cleanup id
-local ref_to_id = {} :: { [string]: number }
--- maps a cleanup id to a ref
-local id_to_ref = {} :: { [number]: string }
--- array of all cleanup callbacks
-local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense
--- weak array of all cleanup lifetimes
-local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse
-setmetatable(cleanup_lifetime :: any, { __mode = "v" })
-
--- detects in strict mode when multiple cleanups are registered in the same scope
-local debug_caller_to_line = {} :: { [() -> ()]: number }
-setmetatable(debug_caller_to_line, { __mode = "k" })
-
--- when active, cleanup callbacks are not automatically registered but are
--- added to an array for manual registering internally
-local manual_mode = {
- caller = false :: false | () -> (),
- callbacks = {} :: { () -> () }
-}
-
--- todo: rare case where mem address is reused by another function
--- does this case handle itself?
-
--- registers a callback with the given lifetime using the given ref
-local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
- local id = ref_to_id[ref]
-
- if id then -- invoke previously registered callback then register new one
- cleanup_callbacks[id]()
- cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil
- else -- no previously registered callback, add and register new one
- id = #cleanup_callbacks + 1
- ref_to_id[ref] = id
- id_to_ref[id :: any] = ref -- todo
- cleanup_lifetime[id :: any] = lifetime -- todo
- end
-
- cleanup_callbacks[id] = callback
-end
-
--- registers a callback with its caller as the lifetime, and caller address as the ref
local function cleanup(callback: () -> ())
- local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup`
+ local scope = get_scope()
+ if not scope then
+ throw("cannot cleanup in a non-reactive scope")
+ end; assert(scope)
- if flags.strict then
- local line = debug.info(2, "l")
- local cur_line = debug_caller_to_line[lifetime]
- if cur_line and cur_line ~= line then
- throw "only one cleanup call is allowed per function scope"
- end
- debug_caller_to_line[lifetime] = line
- end
-
- if manual_mode.caller == lifetime then
- table.insert(manual_mode.callbacks, callback)
- else
- local ref = tostring(lifetime)
- cleanup_ref(ref, lifetime, callback)
- end
+ add_cleanup(scope, callback)
end
-local function clean_garbage()
- for id = #cleanup_callbacks, 1, -1 do
- if cleanup_lifetime[id] == nil then -- lifetime was garbage collected
- local callback = cleanup_callbacks[id]
+return cleanup
- do -- swap and pop
- local max_id = #cleanup_callbacks
-
- cleanup_callbacks[id] = cleanup_callbacks[max_id]
- cleanup_callbacks[max_id] = nil
-
- cleanup_lifetime[id] = cleanup_lifetime[max_id]
- cleanup_lifetime[max_id] = nil
-
- local ref = id_to_ref[id]
- local max_ref = id_to_ref[max_id]
-
- id_to_ref[id] = max_ref
- id_to_ref[max_id] = nil
-
- ref_to_id[max_ref] = id
- ref_to_id[ref] = nil
- end
-
- local ok, err: string? = pcall(callback)
- if not ok then warn(`error occured during cleanup: {err}`) end
- end
- end
-end
-
-local manual_cleanup_mode = function(caller: () -> ()?)
- if caller == nil then
- local clone = table.clone(manual_mode.callbacks)
- manual_mode.caller = false
- table.clear(manual_mode.callbacks)
- return clone
- else
- manual_mode.caller = caller
- end
- return manual_mode.callbacks
-end :: ( (caller: (...any) -> ()) -> () ) & ( (nil) -> { () -> () } )
-
-return function() return cleanup, clean_garbage, manual_cleanup_mode, cleanup_ref end
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 4d08437..29b915a 100644
--- a/src/derive.luau
+++ b/src/derive.luau
@@ -1,15 +1,28 @@
if not game then script = require "test/relative-string" end
+local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
-local create = graph.create
-local capture_and_link = graph.capture_and_link
+local create_node = graph.create_node
+local set_owner = graph.set_owner
+local track = graph.track
+local get_scope = graph.get_scope
+local evaluate_node = graph.evaluate_node
-local function derive(fn: () -> T): () -> T
- local node, read_node_value = create((false :: any) :: 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)
- node.cache = capture_and_link(node, fn)
+ local node = create_node(false :: any, source)
- return read_node_value
+ set_owner(node, owner)
+ evaluate_node(node)
+
+ return function()
+ track(node)
+ return node.cache
+ end
end
return derive
diff --git a/src/effect.luau b/src/effect.luau
new file mode 100644
index 0000000..bc53660
--- /dev/null
+++ b/src/effect.luau
@@ -0,0 +1,22 @@
+if not game then script = require "test/relative-string" end
+
+local throw = require(script.Parent.throw)
+local graph = require(script.Parent.graph)
+local create_node = graph.create_node
+local get_scope = graph.get_scope
+local evaluate_node = graph.evaluate_node
+local set_owner = graph.set_owner
+
+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, callback)
+
+ set_owner(node, owner)
+ evaluate_node(node)
+end
+
+return effect :: ((callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ())
diff --git a/src/graph.luau b/src/graph.luau
index 029067c..20140bd 100644
--- a/src/graph.luau
+++ b/src/graph.luau
@@ -3,25 +3,24 @@ if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
-export type Node = {
+export type StartNode = {
cache: T,
- derive: () -> T,
- effects: { [(unknown) -> ()]: unknown }, -- weak values
- children: { Node } | false -- weak values
+ [number]: Node
}
--- flag used to detect when node reference capturing is active
-local reff = false
--- array of all nodes referenced since above flag was set
-local refs = {} :: { Node }
+export type Node = {
+ cache: T,
+ effect: ((T) -> T) | false,
+ cleanups: { () -> () } | false,
+ parents: { owner: StartNode?, [number]: StartNode },
+ [number]: Node
+}
-local WEAK_VALUES = { __mode = "v" }
-local EVALUATION_ERR = "error while evaluating source:\n\n"
-
-setmetatable(refs :: any, WEAK_VALUES)
+-- 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...) -> unknown, T...) -> () do
+local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do
local t = { __mode = "kv" }
setmetatable(t, t)
@@ -32,149 +31,197 @@ local check_for_yield: (fn: (T...) -> unknown, T...) -> () do
fn(unpack(args))
end
- local ok, err = pcall(function()
+ local ok, err: string? = pcall(function()
local _ = -t
end)
- if not ok then
- if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then
- throw(EVALUATION_ERR .. "cannot yield when deriving node in watcher")
- else
- throw(EVALUATION_ERR .. err)
- end
- end
+ return ok, if err == "attempt to yield across metamethod/C-call boundary"
+ or err == "thread is not yieldable" then "yield occured"
+ else err
end
end
---[[
-
-Each node side-effect is registered with a corresponding weak key.
-This makes the lifetime of the side-effect tied to the key's.
-The main usecase of this is to tie a side-effect to an instance, while allowing
-the instance to be garbage collected even when the node still exists.
-
-The weak key is passed as an argument to its side-effect callback.
-
-]]
-
-local function set_effect(node: Node, fn: (T) -> (), key: T)
- node.effects[fn :: () -> ()] = key
+local function get_scope(): Node?
+ return scopes[scopes.n]
end
-local function run_effects(node: Node)
- if flags.strict then -- run effects twice if strict
- for effect, key in next, node.effects do
- effect(key)
- effect(key)
- end
+local function add_child(parent: StartNode, child: Node)
+ table.insert(parent, child)
+ table.insert(child.parents, parent)
+end
+
+local function set_owner(node: Node, owner: Node)
+ node.parents.owner = owner
+ table.insert(owner, node)
+end
+
+local function open_scope(node: Node)
+ local n = scopes.n + 1
+ scopes.n = n
+ scopes[n] = node
+end
+
+local function close_scope()
+ local n = scopes.n
+ scopes.n = n - 1
+ scopes[n] = nil
+end
+
+local function add_cleanup(node: Node, cleanup: () -> ())
+ if node.cleanups then
+ table.insert(node.cleanups, cleanup)
else
- for effect, key in next, node.effects do
- effect(key)
+ node.cleanups = { cleanup }
+ end
+end
+
+local function run_cleanups(node: Node)
+ if node.cleanups then
+ for _, fn in next, node.cleanups do
+ local ok, err: string? = pcall(fn)
+ if not ok then throw(`cleanup error: {err}`) end
+ end
+ table.clear(node.cleanups)
+ end
+end
+
+local function remove_child(parent: StartNode, child: Node)
+ local idx = table.find(parent, child)
+ assert(idx, "child not found")
+ local n = #parent
+ parent[idx] = parent[n]
+ parent[n] = nil
+end
+
+local function unparent(node: Node)
+ local parents = node.parents
+
+ for i, parent in ipairs(parents) do
+ remove_child(parent, node)
+ parents[i] = nil
+ end
+end
+
+local function destroy(node: Node)
+ run_cleanups(node)
+ unparent(node)
+
+ node.effect = false
+
+ if node.parents.owner then
+ remove_child(node.parents.owner, node)
+ node.parents.owner = nil
+ end
+
+ while node[1] do destroy(node[1]) end
+end
+
+local update_queue = {} :: { Node }
+
+local function evaluate_node(node: Node)
+ local cur_value = node.cache
+
+ 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)
+
+ close_scope()
+
+ if not ok then
+ table.clear(update_queue)
+ throw(`side-effect error from source update\n{new_value}`)
+ end
+
+ node.cache = new_value
+
+ return cur_value ~= new_value -- node has changed value
+end
+
+local function update_from(node: StartNode, n0: number)
+ if not node[1] then return end
+
+ local n = n0
+
+ -- unparent all children and queue for eval
+ do
+ local child = node[1]
+ while child do -- todo: case where child in owner context
+ unparent(child)
+
+ n += 1
+ update_queue[n] = child
+
+ child = node[1]
end
end
-end
--- retrieves a node's cached value
--- add self to refs if ref capture flag is enabled
-local function get(node: Node): T
- if reff then table.insert(refs, node) end
- return node.cache
-end
+ -- evaluate all queued children
+ for i = n0 + 1, n do
+ local child = update_queue[i]
+ if not child.effect then continue end
--- links two nodes as parent-child
-local function set_child(parent: Node, child: Node)
- if parent.children then
- table.insert(parent.children, child)
- else
- parent.children = { child }
- setmetatable(parent.children :: any, WEAK_VALUES)
- end
-end
-
--- runs node effects, recalculates descendants and runs descendant effects
-local function update(node: Node)
- run_effects(node)
- if node.children then
- local strict = flags.strict
-
- for _, child in node.children do
- if strict then check_for_yield(child.derive) end
- child.cache = child.derive()
- update(child)
+ if evaluate_node(child) then
+ update_from(child, n)
end
+
+ update_queue[i] = false :: any -- false instead of nil to avoid sparse
end
end
--- sets a node's cached value and updates all descendants
-local function set(node: Node, value: T)
- node.cache = value
- update(node)
+local function update(node: StartNode)
+ update_from(node, 0)
end
--- links two nodes as parent-child with a function to compute a new value for child
-local function link(parent: Node, child: Node, derive: () -> T)
- child.derive = derive
- set_child(parent, child)
-end
-
--- detect what nodes were referenced in the given callback and returns them in an array
-local function capture(fn: (U?) -> T, arg: U?): ({ Node }, T)
- if reff then throw("recursive capture detected") end
-
- if flags.strict then check_for_yield(fn, arg) end
-
- table.clear(refs)
- reff = true
-
- local ok: boolean, result: T|string
-
- if arg == nil then
- ok, result = pcall(fn)
- else
- ok, result = pcall(fn, arg)
+local function track(node: StartNode)
+ local scope = get_scope()
+ if scope and scope.effect then -- do not track nodes with no effect
+ add_child(node, scope)
end
-
- reff = false
-
- if not ok then throw(EVALUATION_ERR .. result :: string) end
-
- return refs, result :: T
end
--- captures and links any detected nodes
-local function capture_and_link(child: Node, fn: () -> T): T
- local nodes, value = capture(fn, nil)
-
- child.derive = fn
- for _, parent: Node in next, nodes do
- set_child(parent, child)
- end
-
- return value :: T
-end
-
-local function create(value: T): (Node, () -> T)
- local node = {
+local function create_node(value: T, effect: false | (T) -> T): Node
+ return {
cache = value,
- derive = function() return nil :: any end,
- effects = setmetatable({}, WEAK_VALUES) :: any,
- children = false :: false
+ effect = effect,
+ cleanups = false,
+ parents = {},
}
+end
- local function read_node_value()
- return get(node)
- end
+local function create_start_node(value: T): StartNode
+ return { cache = value }
+end
- return node, read_node_value
+local function get_children(node: Node): { Node }
+ return { unpack(node) } :: { Node }
end
return table.freeze {
- set_effect = set_effect,
- get = get,
- set = set,
- link = link,
- capture = capture,
- capture_and_link = capture_and_link,
- create = create :: ((value: T) -> (Node, () -> T)) & (() -> (Node, () -> T)),
- refs = refs
+ open_scope = open_scope,
+ close_scope = close_scope,
+ evaluate_node = evaluate_node,
+ get_scope = get_scope,
+ add_cleanup = add_cleanup,
+ set_owner = set_owner,
+ destroy = destroy,
+ run_cleanups = run_cleanups,
+ track = track,
+ update = update,
+ add_child = add_child,
+ create_node = create_node,
+ create_start_node = create_start_node,
+ get_children = get_children,
+ scopes = scopes
}
diff --git a/src/init.luau b/src/init.luau
index 416ac75..8958dd9 100644
--- a/src/init.luau
+++ b/src/init.luau
@@ -5,16 +5,20 @@
if not game then script = require "test/relative-string" end
+local root = require(script.root)
+local mount = require(script.mount)
local create = require(script.create)
local apply = require(script.apply)
local source = require(script.source)
-local watch = require(script.watch)
-local cleanup, clean_garbage = require(script.cleanup)()
+local effect = require(script.effect)
+local cleanup = require(script.cleanup)
local untrack = require(script.untrack)
local derive = require(script.derive)
+local switch = require(script.switch)
local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)()
local action = require(script.action)()
+local changed = require(script.changed)
local throw = require(script.throw)
local flags = require(script.flags)
@@ -28,13 +32,6 @@ local function step(dt: number)
update_springs(dt)
- if game then
- debug.profileend()
- debug.profilebegin("VIDE GARBAGE CLEANUP")
- end
-
- clean_garbage()
-
if game then
debug.profileend()
debug.profileend()
@@ -47,10 +44,13 @@ end)
local vide = {
-- core
+ root = root,
+ mount = mount,
create = create,
source = source,
- watch = watch,
+ effect = effect,
derive = derive,
+ switch = switch,
indexes = indexes,
values = values,
@@ -63,6 +63,7 @@ local vide = {
-- actions
action = action,
+ changed = changed,
-- flags
strict = (nil :: any) :: boolean,
diff --git a/src/maps.luau b/src/maps.luau
index 977cfe2..da5c5d7 100644
--- a/src/maps.luau
+++ b/src/maps.luau
@@ -1,16 +1,20 @@
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)
-local _, _, manual_cleanup_mode, cleanup_ref = require(script.Parent.cleanup)()
type Node = graph.Node
-local create = graph.create
-local set = graph.set
-local capture = graph.capture
-local link = graph.link
+type StartNode = graph.StartNode
+local create_node = graph.create_node
+local create_start_node = graph.create_start_node
+local set_owner = graph.set_owner
+local track = graph.track
+local update = graph.update
+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 destroy = graph.destroy
type Map = { [K]: V }
@@ -23,17 +27,22 @@ local function check_primitives(t: {})
end
end
--- todo: optimize output array
local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO }
+ local owner = get_scope()
+ if not owner then
+ throw("cannot derive in non-reactive scope")
+ end; assert(owner)
+
+ local subowner = create_node(false, false)
+ set_owner(subowner, owner)
+
local input_cache = {} :: Map
local output_cache = {} :: Map
- local input_nodes = {} :: Map>
+ local input_nodes = {} :: Map>
local remove_queue = {} :: { K }
- local output_array = {} :: { VO }
+ local scopes = {} :: Map>
- local cleanups = {} :: Map () }>
-
- local function recompute(data)
+ local function update_children(data)
-- queue removed values
for i in next, input_cache do
if data[i] == nil then
@@ -43,41 +52,58 @@ local function indexes(input: () -> Map, transform: (() -> VI,
-- remove queued values
for _, i in next, remove_queue do
- for _, callback in next, cleanups[i] do
- callback() -- todo: pcall
- end
+ destroy(scopes[i])
input_cache[i] = nil
output_cache[i] = nil
input_nodes[i] = nil
- cleanups[i] = nil
+ scopes[i] = nil
end
table.clear(remove_queue)
+ open_scope(subowner)
+
-- process new or changed values
for i, v in next, data do
local cv = input_cache[i]
if cv ~= v then
- if cv == nil then
- manual_cleanup_mode(transform)
+ if cv == nil then -- create new scope and run transform
+ local scope = create_node(false, false)
+ scopes[i] = scope :: Node
- local node, get_value = create(v)
+ local node = create_start_node(v)
+
+ set_owner(scope, subowner)
+ open_scope(scope)
+
+ local ok, result = pcall(transform, function()
+ track(node)
+ return node.cache
+ end, i)
+
+ close_scope()
+
+ if not ok then
+ close_scope() -- subowner scope
+ error(result, 0)
+ end
+
input_nodes[i] = node
- output_cache[i] = transform(get_value, i)
- input_cache[i] = v
-
- cleanups[i] = manual_cleanup_mode(nil)
- else
- set(input_nodes[i], v)
- input_cache[i] = v
+ output_cache[i] = result
+ else -- update source
+ input_nodes[i].cache = v
+ update(input_nodes[i])
end
+
+ input_cache[i] = v
end
end
- -- output elements
- table.clear(output_array)
+ close_scope()
+
+ local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
@@ -86,43 +112,34 @@ local function indexes(input: () -> Map, transform: (() -> VI,
return output_array
end
- local output, read_output_value = create(nil :: any)
-
- local function derive()
- return recompute(input())
- end
-
- local nodes, value = capture(input)
-
- for _, node in next, nodes do
- link(node, output, derive)
- end
-
- output.cache = recompute(value)
-
- cleanup_ref(tostring(output), output, function()
- for _, callbacks in next, cleanups do
- for _, callback in next, callbacks do
- callback() -- todo: pcall
- end
- end
+ local node = create_node(false :: any, function()
+ return update_children(input())
end)
- return read_output_value
+ evaluate_node(node)
+
+ return function()
+ track(node)
+ return node.cache
+ end
end
--- todo: optimize output array
local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO }
+ local owner = get_scope()
+ if not owner then
+ throw("cannot derive in non-reactive scope")
+ end; assert(owner)
+
+ local subowner = create_node(false, false)
+ set_owner(subowner, owner)
+
local cur_input_cache_up = {} :: Map
local new_input_cache_up = {} :: Map
-
local output_cache = {} :: Map
- local input_nodes = {} :: Map>
- local output_array = {} :: { VO }
+ local input_nodes = {} :: Map>
+ local scopes = {} :: Map>
- local cleanups = {} :: Map () }>
-
- local function recompute(data: Map)
+ local function update_children(data: Map)
local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up
if flags.strict then
@@ -134,6 +151,8 @@ local function values(input: () -> Map, transform: (VI, () ->
cache[v] = true
end
end
+
+ open_scope(subowner)
-- process data
for i, v in next, data do
@@ -141,71 +160,73 @@ local function values(input: () -> Map, transform: (VI, () ->
local cv = cur_input_cache[v]
- if cv == nil then
- manual_cleanup_mode(transform)
+ if cv == nil then -- create new scope and run transform
+ local scope = create_node(false, false)
+ scopes[v] = scope :: Node
- local node, get_value = create(i)
- input_nodes[v] = node
- output_cache[v] = transform(v, get_value)
+ local node = create_start_node(i)
+
+ set_owner(scope, subowner)
+ open_scope(scope)
+
+ local ok, result = pcall(transform, v, function()
+ track(node)
+ return node.cache
+ end)
+
+ close_scope()
- cleanups[v] = manual_cleanup_mode(nil)
- else
- if cv ~= i then
- set(input_nodes[v], i)
+ if not ok then
+ close_scope() -- subowner scope
+ error(result, 0)
end
+
+ input_nodes[v] = node
+ output_cache[v] = result
+ else -- update source
+ if cv ~= i then
+ input_nodes[v].cache = i
+ update(input_nodes[v])
+ end
+
cur_input_cache[v] = nil
end
end
+ close_scope()
+
-- remove old values
for v in next, cur_input_cache do
- for _, callback in next, cleanups[v] do
- callback() -- todo: pcall
- end
+ destroy(scopes[v])
output_cache[v] = nil
input_nodes[v] = nil
- cleanups[v] = nil
+ scopes[v] = nil
end
-- update buffer cache
table.clear(cur_input_cache)
cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache
- -- output elements
- table.clear(output_array)
-
+ local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
+ check_primitives(output_array)
return output_array
end
- local output, read_output_value = create(nil :: any)
-
- local function derive()
- return recompute(input())
- end
-
- local nodes, value = capture(input)
-
- for _, node in next, nodes do
- link(node, output, derive)
- end
- check_primitives(output_array)
-
- output.cache = recompute(value)
-
- cleanup_ref(tostring(output), output, function()
- for _, callbacks in next, cleanups do
- for _, callback in next, callbacks do
- callback() -- todo: pcall
- end
- end
+ local node = create_node(false :: any, function()
+ return update_children(input())
end)
- return read_output_value
+ evaluate_node(node)
+
+ return function()
+ track(node)
+ return node.cache
+ end
end
return function() return indexes, values end
diff --git a/src/mount.luau b/src/mount.luau
new file mode 100644
index 0000000..315925e
--- /dev/null
+++ b/src/mount.luau
@@ -0,0 +1,14 @@
+if not game then script = require "test/relative-string" end
+
+local root = require(script.Parent.root)
+local apply = require(script.Parent.apply)
+
+local function mount(component: () -> T, target: Instance?): () -> ()
+ return root(function(destroy)
+ local result = component()
+ if target then apply(target, { result }) end
+ return destroy
+ end)
+end
+
+return mount :: ((component: () -> T, target: Instance) -> () -> ()) & ((component: () -> ()) -> () -> ())
diff --git a/src/root.luau b/src/root.luau
new file mode 100644
index 0000000..a2900cd
--- /dev/null
+++ b/src/root.luau
@@ -0,0 +1,38 @@
+if not game then script = require "test/relative-string" end
+
+local throw = require(script.Parent.throw)
+local graph = require(script.Parent.graph)
+type Node = graph.Node
+local create_node = graph.create_node
+local open_scope = graph.open_scope
+local close_scope = graph.close_scope
+local destroy = graph.destroy
+
+local refs = {}
+
+local function root(fn: (destroy: () -> ()) -> T...): T...
+ local node = create_node(false, false)
+
+ refs[node] = true -- prevent gc of root node
+
+ local destroy = function()
+ if not refs[node] then throw "root already destroyed" end
+ refs[node] = nil
+ destroy(node)
+ end
+
+ open_scope(node)
+
+ local result = { pcall(fn, destroy) }
+
+ close_scope()
+
+ if not result[1] then
+ refs[node] = nil
+ throw(`mount error\n{result}`)
+ end
+
+ return unpack(result :: any, 2)
+end
+
+return root :: ((fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ())
diff --git a/src/source.luau b/src/source.luau
index d0da051..f1307fc 100644
--- a/src/source.luau
+++ b/src/source.luau
@@ -2,23 +2,30 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
type Node = graph.Node
-local create = graph.create
-local set = graph.set
+local create_start_node = graph.create_start_node
+local track = graph.track
+local update = graph.update
export type Source = (() -> T) & ((T) -> T)
-local function source(value: T): Source
- local node, read_node_value = create(value :: T)
+local function source(initial_value: T): Source
+ local node = create_start_node(initial_value)
return function(...): T
- if select("#", ...) == 0 then return read_node_value() end -- check if any args were given
+ if select("#", ...) == 0 then -- no args were given
+ track(node)
+ return node.cache
+ end
local v = ... :: T
- if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then return v end
+ if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then
+ return v
+ end
- set(node, v)
+ node.cache = v
+ update(node)
return v
end
end
-return source :: ((value: T) -> Source) & (() -> Source)
+return source :: ((initial_value: T) -> Source) & (() -> Source)
diff --git a/src/spring.luau b/src/spring.luau
index 1f8fdb3..9503e15 100644
--- a/src/spring.luau
+++ b/src/spring.luau
@@ -24,10 +24,14 @@ Unsupported datatypes:
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node = graph.Node
-local create = graph.create
-local set = graph.set
-local set_effect = graph.set_effect
-local capture = graph.capture
+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 evaluate_node = graph.evaluate_node
+local update = graph.update
+local set_owner = graph.set_owner
+local track = graph.track
local UPDATE_RATE = 120
local TOLERANCE = 0.0001
@@ -38,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 = {
@@ -62,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)
@@ -84,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)
@@ -141,19 +146,16 @@ setmetatable(vec6_to_type, invalid_type)
-- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node
-local springs: { [SpringData]: Node } = {}
+local springs: { [SpringData]: StartNode } = {}
setmetatable(springs, { __mode = "v" })
local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T
- local inputs, initial_value = capture(source)
- local output, output_get = create(initial_value)
-
- local vtype = typeof(initial_value)
-
- local x1_123, x1_456 = type_to_vec6[vtype](initial_value)
+ local owner = get_scope()
+ 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
@@ -166,34 +168,42 @@ 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(false :: any)
- -- reschedule spring for simulation on input update
- local function input_updated(node)
- local v = source()
- data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v)
- data.source_value = v
- springs[data] = node -- todo: investigate why insertion is not O(1) at ~20k springs
+ 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 value
end
- -- unused field, use so output prevents gc of inputs
- output.derive = source :: any
+ local updater = create_node(false :: any, updater_effect)
- -- register above function as side-effect for all inputs
- for _, input in next, inputs do
- set_effect(input, input_updated, output)
+ 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
end
-
- return output_get, data
end
local function step_springs(dt: number)
@@ -251,10 +261,12 @@ local function update_spring_sources()
if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then
-- close enough to target, unshedule spring and set value to target
table.insert(remove_queue, data)
- set(output, data.source_value)
+ output.cache = data.source_value
else
- set(output, vec6_to_type[typeof(data.source_value)](x0_123, x0_456))
+ output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456)
end
+
+ update(output)
end
for _, data in next, remove_queue do
diff --git a/src/switch.luau b/src/switch.luau
new file mode 100644
index 0000000..31c2b4a
--- /dev/null
+++ b/src/switch.luau
@@ -0,0 +1,71 @@
+if not game then script = require "test/relative-string" end
+
+local throw = require(script.Parent.throw)
+local graph = require(script.Parent.graph)
+type Node = graph.Node
+type StartNode = graph.StartNode
+local create_node = graph.create_node
+local evaluate_node = graph.evaluate_node
+local set_owner = graph.set_owner
+local track = graph.track
+local destroy = graph.destroy
+local get_scope = graph.get_scope
+local open_scope = graph.open_scope
+local close_scope = graph.close_scope
+
+type Map = { [K]: V }
+
+local function switch(source: () -> T): (map: Map U)?)>) -> () -> U?
+ return function(map)
+ local owner = get_scope()
+ if not owner then
+ throw("cannot switch in non-reactive scope")
+ end; assert(owner)
+
+ local last_scope: Node?
+ local last_component: (() -> U)?
+
+ local function update(cached): U?
+ local component = map[source()]
+ if component == last_component then return cached end
+ last_component = component
+
+ 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)
+ last_scope = new_scope :: Node
+
+ set_owner(new_scope, owner)
+ open_scope(new_scope)
+
+ local ok, result = pcall(component)
+
+ close_scope()
+
+ if not ok then error(result, 0) end
+
+ return result
+ end
+
+ local node = create_node(nil :: any, update)
+
+ set_owner(node, owner)
+ evaluate_node(node)
+
+ return function()
+ track(node)
+ return node.cache
+ end
+ end
+end
+
+return switch
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 ec1e774..230674d 100644
--- a/src/untrack.luau
+++ b/src/untrack.luau
@@ -1,20 +1,27 @@
if not game then script = require "test/relative-string" end
+local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node = graph.Node
-local refs = graph.refs
+local get_scope = graph.get_scope
local function untrack(source: () -> T): T
- local initial = #refs
+ local scope = get_scope()
+ if not scope then
+ throw("cannot untrack in non-reactive scope")
+ end; assert(scope)
- local value = source()
+ -- sources are only tracked if the node in scope has an effect
+ local effect = scope.effect
+ scope.effect = false
- -- remove any references made since `untrack()` was called
- for i = initial, #refs do
- refs[i] = nil
- end
+ local ok, result = pcall(source)
- return value
+ scope.effect = effect :: () -> ()
+
+ if not ok then error(result, 0) end
+
+ return result
end
return untrack
diff --git a/src/watch.luau b/src/watch.luau
deleted file mode 100644
index 27233ca..0000000
--- a/src/watch.luau
+++ /dev/null
@@ -1,28 +0,0 @@
-if not game then script = require "test/relative-string" end
-
-local graph = require(script.Parent.graph)
-local set_effect = graph.set_effect
-local capture = graph.capture
-
-local function watch(effect: () -> ()): () -> ()
- local nodes = capture(effect :: () -> nil)
-
- -- store aside captured nodes in new table
- nodes = table.clone(nodes)
-
- -- register effect with permanent lifetime
- for _, node in next, nodes do
- set_effect(node, effect, true)
- end
-
- local function unwatch()
- -- unregister effect from all nodes
- for _, node in next, nodes do
- set_effect(node, effect, nil)
- end
- end
-
- return unwatch
-end
-
-return watch
diff --git a/test/benchmark.luau b/test/benchmark.luau
index 4718aae..6702235 100644
--- a/test/benchmark.luau
+++ b/test/benchmark.luau
@@ -1,167 +1,291 @@
-local BENCH, START = require("test/testkit").benchmark()
-
--- try prevent inlining by wrapping in a closure referencing an upvalue that
--- cannot be determined at compile-time
-local function NO_INLINE(fn)
- local r = math.random()
- return function(x)
- local _ = r
- fn(x)
- end
-end
+local testkit = require("test/testkit")
+local BENCH, START = testkit.benchmark()
local vide = require "src/init"
+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[
+local function TITLE(name: string)
+ print()
+ print(testkit.color.white(name))
+end
-BENCH("create state", function()
+local N = 2^18 -- 262144
+
+local function WRAP_BENCH(name: string, fn: () -> ())
+ vide.root(function(destroy)
+ BENCH(name, fn)
+ return destroy
+ end)()
+end
+
+TITLE "sources"
+
+WRAP_BENCH("create source", function()
local cache = table.create(N)
- local source = vide.source
for i = 1, START(N) do
cache[i] = source(1)
end
end)
-BENCH("get value", function()
- local state = vide.source(1)
+WRAP_BENCH("get value", function()
+ local src = source(1)
for i = 1, START(N) do
- state()
+ src()
end
end)
-BENCH("set value", function()
- local state = vide.source(1)
+WRAP_BENCH("set value", function()
+ local src = source(1)
for i = 1, START(N) do
- state(i)
+ src(i)
end
end)
-BENCH("derive 1 state", function()
+WRAP_BENCH("derive 1 source", function()
local cache = table.create(N)
- local state = vide.source(1)
- local derive = vide.derive
+ local src = source(1)
for i = 1, START(N) do
cache[i] = derive(function()
- return state()
+ return src()
end)
end
end)
-BENCH("derive 4 states", function()
+WRAP_BENCH("derive 4 sources", function()
local cache = table.create(N)
- local state = vide.source(1)
- local state2 = vide.source(2)
- local state3 = vide.source(3)
- local state4 = vide.source(4)
- local derive = vide.derive
+ local src = vide.source(1)
+ local src2 = vide.source(2)
+ local src3 = vide.source(3)
+ local src4 = vide.source(4)
for i = 1, START(N) do
cache[i] = derive(function()
- return state() + state2() + state3() + state4()
+ return src() + src2() + src3() + src4()
end)
end
end)
-BENCH("set derived value", function()
- local state = vide.source(1)
- local _derived = vide.derive(state)
+TITLE "graphs"
+
+WRAP_BENCH("update 1->1 graph", function()
+ local src = source(1)
+
+ local _derived = derive(function() return src() end)
for i = 1, START(N) do
- state(i)
+ src(i)
end
end)
-BENCH("apply 0 properties", function()
+WRAP_BENCH("update 1->1 graph with cleanup", function()
+ local src = source(1)
+
+ derive(function()
+ cleanup(function() end)
+ return src()
+ end)
+
+ for i = 1, START(N) do
+ src(i)
+ end
+end)
+
+WRAP_BENCH("update 1->1000 graph", function()
+ local src = source(-1)
+
+ for i = 1, 1000 do
+ derive(function() return src() end)
+ end
+
+ src(0)
+
+ for i = 1, START(10) do
+ src(i)
+ end
+end)
+
+WRAP_BENCH("update 1->1->1->1...1000 graph", function()
+ local src = source(-1)
+
+ 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)
+
+-- todo: repeat with batching
+WRAP_BENCH("update 1000->1 graph", function()
+ local srcs = {}
+ for i = 1, 1000 do
+ srcs[i] = source(0)
+ end
+
+ 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)
+
+-- todo: optimize, repeat with batching
+WRAP_BENCH("update 1000x 1->1 common extern. graph", function()
+ local ext = source(-1)
+
+ 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)
+
+TITLE "property apply"
+
+WRAP_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, {})
end
end)
-BENCH("apply 8 properties", function()
+WRAP_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, {
- Name = i,
- Name2 = i,
- Name3 = i,
- Name4 = i,
- Name5 = i,
- Name6 = i,
- Name7 = i,
- Name8 = i,
+ Text = i,
+ Text2 = i,
+ Text3 = i,
+ Text4 = i,
+ Text5 = i,
+ Text6 = i,
+ Text7 = i,
+ Text8 = i,
})
end
end)
-BENCH("bind source", function()
+WRAP_BENCH("bind property", function()
local apply = require "src/apply"
- local instance = vide.create("Frame") {}
- local state = vide.source(1)
+
+ local instance = create("Frame") {}
+ local src = source(1)
for i = 1, START(N) do
apply(instance, {
- Name = state
+ Text = src
})
end
+
+ return nil
end)
-BENCH("update binding", function()
+WRAP_BENCH("update binding", function()
local apply = require "src/apply"
- local instance = vide.create("Frame") {}
- local state = vide.source(1)
+
+ local instance = create("Frame") {}
+ local src = source(1)
apply(instance, {
- Name = state
+ Text = src
})
for i = 1, START(N) do
- state(i)
+ src(i)
end
+
+ return nil
end)
-BENCH("indexes() no change", function()
+TITLE "indexes()"
+
+N /= 1024
+
+WRAP_BENCH("indexes() all new", function()
local data = {}
for i = 1, N do
data[i] = i
end
- local state = vide.source(data)
-
- local _list = vide.indexes(state, function(v, i)
- return {}
- end)
-
- --state(state()) -- fill double buffer
+ local src = source(data)
START(N)
- state(data)
+ local _list = indexes(src, function(v, i)
+ return {}
+ end)
+
+ return nil
end)
-BENCH("indexes() all change", function()
+WRAP_BENCH("indexes() no change", function()
local data = {}
for i = 1, N do
data[i] = i
end
- local state = vide.source(data)
+ local src = source(data)
- local _list = vide.indexes(state, function(v, i)
+ local _list = indexes(src, function(v, i)
return {}
end)
- --state(state()) -- fill double buffer
+ START(N)
+
+ src(data)
+
+ return nil
+end)
+
+WRAP_BENCH("indexes() all change", function()
+ local data = {}
+
+ for i = 1, N do
+ data[i] = i
+ end
+
+ local src = source(data)
+
+ local _list = indexes(src, function(v, i)
+ return {}
+ end)
+
+ --src(src()) -- fill double buffer
for i, v in data do
data[i] = v + 1
@@ -169,19 +293,19 @@ BENCH("indexes() all change", function()
START(N)
- state(data)
+ src(data)
end)
-BENCH("indexes() all remove", function()
+WRAP_BENCH("indexes() all remove", function()
local data = {}
for i = 1, N do
data[i] = i
end
- local state = vide.source(data)
+ local src = source(data)
- local _list = vide.indexes(state, function(v, i)
+ local _list = indexes(src, function(v, i)
return {}
end)
@@ -189,43 +313,65 @@ BENCH("indexes() all remove", function()
START(N)
- state(data)
+ src(data)
+
+ return nil
end)
-BENCH("values() no change", function()
+TITLE "values()"
+
+WRAP_BENCH("values() all new", function()
local data = {}
for i = 1, N do
data[i] = {}
end
- local state = vide.source(data)
+ local src = source(data)
- local _list = vide.values(state, function(v, i)
+ START(N)
+
+ local _list = values(src, function(v, i)
return {}
end)
- state(state()) -- fill double buffer
+ return nil
+end)
+
+WRAP_BENCH("values() no change", function()
+ local data = {}
+
+ for i = 1, N do
+ data[i] = {}
+ end
+
+ local src = source(data)
+
+ local _list = values(src, function(v, i)
+ return {}
+ end)
+
+ src(src()) -- fill double buffer
START(N)
- state(data)
+ src(data)
end)
-BENCH("values() all change", function()
+WRAP_BENCH("values() all change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
- local state = vide.source(data)
+ local src = source(data)
- local _list = vide.values(state, function(v, i)
+ local _list = values(src, function(v, i)
return {}
end)
- state(state()) -- fill double buffer
+ src(src()) -- fill double buffer
for i = 1, N do
local r = math.random(1, #data)
@@ -234,19 +380,19 @@ BENCH("values() all change", function()
START(N)
- state(data)
+ src(data)
end)
-BENCH("values() all remove", function()
+WRAP_BENCH("values() all remove", function()
local data = {}
for i = 1, N do
data[i] = {}
end
- local state = vide.source(data)
+ local src = source(data)
- local _list = vide.values(state, function(v, i)
+ local _list = values(src, function(v, i)
return {}
end)
@@ -254,11 +400,15 @@ BENCH("values() all remove", function()
START(N)
- state(data)
+ src(data)
end)
-BENCH("register new cleanup", function()
- local cleanup = vide.cleanup
+N *= 1024
+
+TITLE "cleanup"
+
+WRAP_BENCH("register new cleanup", function()
+ local cleanup = cleanup
local cleaner = function() end
@@ -276,61 +426,12 @@ BENCH("register new cleanup", function()
end
end)
--- cleanup cleanups from previous benchmark
-;(collectgarbage :: any)("collect")
-vide.step(0)
-
-BENCH("repeat cleanup", function()
- local cleanup = vide.cleanup
-
- local foo = NO_INLINE(function(i)
- cleanup(function()
- -- return i -- uncomment to include overhead of closure creation
- end)
- end)
-
- for i = 1, START(N) do
- foo(i)
- end
-end)
-
--- cleanup cleanups from previous benchmark
-;(collectgarbage :: any)("collect")
-vide.step(0)
-
-BENCH("cleanup gc check", function()
- local cleanup = vide.cleanup
-
- local cleaner = function() end
-
- local callers = table.create(N)
-
- for i = 1, N do
- callers[i] = function()
- cleanup(cleaner)
- return i
- end
-
- callers[i]()
- end
-
- START(N)
-
- vide.step(0)
-end)
-
-BENCH("cleanup gc removal", function()
- START(N)
-
- vide.step(0) -- cleanup from previous benchmark
-end)
-
+TITLE "aggregate"
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
+ WRAP_BENCH("set explicit mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@@ -345,8 +446,7 @@ do
end
end)
- BENCH("set aggregate mock vector2", function()
- local create = vide.create
+ WRAP_BENCH("set aggregate mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@@ -362,4 +462,47 @@ do
end)
end
+-- innacurate due to no Vector3 in vanilla Luau
+-- mock vector is 200x slower than native vector
+
+-- WRAP_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
+
+-- WRAP_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/mock.luau b/test/mock.luau
index 0c76dcb..1dfb9bc 100644
--- a/test/mock.luau
+++ b/test/mock.luau
@@ -113,6 +113,7 @@ local Instance = {} :: any do
local function __newindex(userdata: userdata, property: string, value: unknown)
local data = get_data(userdata)
if property == "Name" then
+ if type(value) ~= "string" then error("name must be a string", 2) end
data.name = value :: string
elseif property == "Parent" then
assert(value == nil or is_instance(value), "attempt to set non-instance as parent")
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 f2f19f3..c22d955 100644
--- a/test/spring-test.luau
+++ b/test/spring-test.luau
@@ -37,12 +37,12 @@ local function main()
local source = vide.source
local spring = vide.spring
- local watch = vide.watch
+ local effect = vide.effect
local value = source(MAX)
local sprung = spring(value, 1, 0.3)
- watch(function()
+ effect(function()
local v = sprung()
local fv = math.floor(v)
local reset = "\27[H\27[2J" -- ANSI clear terminal
@@ -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 da7a3ec..4d8a52c 100644
--- a/test/tests.luau
+++ b/test/tests.luau
@@ -6,6 +6,10 @@ local Instance, Signal = mock.Instance, mock.Signal
local Vector2, UDim2 = mock.Vector2, mock.UDim2
local vide = require "src/init"
+local graph = require "src/graph"
+type Node = graph.Node
+
+type Map = { [K] : V }
local function gc(n: number?)
for i = 1, n or 3 do
@@ -18,615 +22,603 @@ local function weak(t: T & {}): T
return t
end
+local function wrap_root(fn: () -> ())
+ return function()
+ local destroy = vide.mount(fn :: any)
+ destroy()
+ end
+end
+
+local NIL = nil :: any
+
TEST("graph", function()
- local graph = require "src/graph"
- local create = graph.create
- local get = graph.get
- local set = graph.set
- local capture = graph.capture
- local capture_and_link = graph.capture_and_link
- local link = graph.link
- local set_effect = graph.set_effect
+ local create_node = graph.create_node
+ local track = graph.track
+ local update = graph.update
+ local add_child = graph.add_child
+ local get_scope = graph.get_scope
+ local open_scope = graph.open_scope
+ local close_scope = graph.close_scope
+ local get_children = graph.get_children
+ local add_cleanup = graph.add_cleanup
+ local destroy = graph.destroy
- do CASE "node creation"
- local node = create(1)
- CHECK(get(node) == 1)
+ local function node(v: T?)
+ return create_node(v or false, function(x) return not x end)
end
- do CASE "node value"
- local node = create(0)
- set(node, 1)
- CHECK(get(node) == 1)
- set(node, 2)
- CHECK(get(node) == 2)
+ local function scope()
+ return create_node(false, false)
end
- do CASE "capture nodes"
- local node1 = create(nil)
- local node2 = create(nil)
- local nodes = capture(function()
- return get(node1), get(node2)
- end)
- CHECK(nodes[1] == node1)
- CHECK(nodes[2] == node2)
+ local function cleanup(fn: () -> ())
+ local node = assert(get_scope())
+ add_cleanup(node, fn)
end
- do CASE "linking nodes"
- local parent = create(1)
- local child = create(0)
+ do CASE "link nodes"
+ local a = node()
+ local b = node()
+ local c = node()
- link(parent, child, function()
- return get(parent)
- end)
+ open_scope(c)
- set(parent, get(parent) + 1)
- CHECK(get(child) == 2) -- child should automatically update
+ track(a)
+ track(b)
+
+ close_scope()
+
+ CHECK(get_children(a)[1] == c)
+ CHECK(get_children(b)[1] == c)
end
- do CASE "capture and link nodes"
- local parent = create(1)
- local child = create()
+ do CASE "rerun linked nodes"
+ local a = node()
+ local b = node()
+ local c = node()
- child.cache = capture_and_link(child, function()
- return tostring(get(parent))
- end)
+ local count = 0
- set(parent, 2)
- CHECK(get(child) == "2")
+ local function effect(x)
+ track(a)
+ track(b)
+ count += 1
+ return not x
+ end
+
+ c.effect = effect
+
+ open_scope(c)
+
+ effect(c.cache)
+
+ close_scope()
+
+ CHECK(count == 1)
+ update(a)
+ CHECK(count == 2)
+ update(b)
+ CHECK(count == 3)
+ end
+
+ do CASE "diamond graph"
+ local a, b, c, d = node(), node(), node(), node()
+
+ local b_cnt, c_cnt, d_cnt = 0, 0, 0
+ 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()
+ open_scope(d); track(b); track(c); close_scope()
+
+ update(a)
+
+ CHECK(b_cnt == 1)
+ CHECK(c_cnt == 1)
+ CHECK(d_cnt == 1)
+ end
+
+ do CASE "duplicate child on rerun"
+ local a, b, c = node(), node(), node()
+
+ function c.effect(x)
+ track(a)
+ track(b)
+ return not x
+ end
+
+ open_scope(c); assert(c.effect)(NIL); close_scope()
+
+ update(a)
+
+ CHECK(#get_children(a) == 1)
+ CHECK(#get_children(b) == 1)
+ end
+
+ do CASE "case 1"
+ -- construct graph
+
+ local items = node { "a", "b" }
+ local selected = node "a"
+
+ local root = scope()
+
+ local scope1 = scope()
+ local scope2 = scope()
+
+ local items_updated
+
+ local bind1
+ local bind2
+
+ local cleaned = {} :: { [any]: any }
+
+ local function clean(s)
+ cleanup(function()
+ cleaned[s] = true
+ end)
+ end
+
+ do open_scope(root)
+ clean "root"
+ items_updated = node()
+ track(items_updated) -- should not
+
+ add_child(root, items_updated)
+ do open_scope(items_updated)
+ track(items)
+
+ do open_scope(root)
+ add_child(root, scope1)
+ do open_scope(scope1)
+ clean "scope1"
+ bind1 = node()
+
+ add_child(scope1, bind1)
+ do open_scope(bind1)
+ clean "bind1"
+ track(selected)
+ close_scope() end
+ close_scope() end
+
+ add_child(root, scope2)
+ do open_scope(scope2)
+ clean "scope2"
+ bind2 = node()
+ add_child(scope2, bind2)
+ do open_scope(bind2)
+ clean "bind2"
+ track(selected)
+ close_scope() end
+ close_scope() end
+ close_scope() end
+ close_scope() end
+ close_scope() end
+
+
+ -- verify graph
+
+ do
+ local c = get_children(items_updated)
+ CHECK(#c == 0)
+ end
+
+ do
+ local c = get_children(root)
+ CHECK(#c == 3)
+ CHECK(table.find(c, items_updated))
+ CHECK(table.find(c, scope1 :: Node))
+ CHECK(table.find(c, scope2 :: Node))
+ end
+
+ do
+ local c = get_children(selected)
+ CHECK(#c == 2)
+ CHECK(table.find(c, bind1))
+ CHECK(table.find(c, bind2))
+ end
+
+ do
+ local c = get_children(scope1)
+ CHECK(#c == 1)
+ CHECK(table.find(c, bind1))
+ end
+
+ do
+ local c = get_children(scope2)
+ CHECK(#c == 1)
+ CHECK(table.find(c, bind2))
+ end
+
+ -- destroy
+
+ CHECK(table.find(get_children(root), scope1 :: Node))
+
+ destroy(scope1)
+ CHECK(cleaned.scope1)
+ CHECK(cleaned.bind1)
+ scope1 = NIL
+ bind1 = NIL
+ bind2 = NIL
+ gc()
+ CHECK(#get_children(root) == 2)
+ CHECK(#get_children(selected) == 1)
end
do CASE "nodes garbage collection"
- local wref = weak { create(1) }
+ local wref = weak { node(1) }
+ destroy(wref[1])
gc()
CHECK(not wref[1])
end
-
- do CASE "node effect garbage collection"
- do
- local wref
-
- do
- local function factory(p) -- factory function to prevent closure caching
- return function()
- return get(p)
- end
- end
-
- local node = create(1)
-
- do
- local effect1 = factory(node)
- local effect2 = factory(node)
-
- wref = weak { e1 = effect1, e2 = effect2, n = node}
-
- set_effect(node, effect1, {})
- set_effect(node, effect2, true)
- end
-
- gc()
- CHECK(not wref.e1) -- effect1 should gc since nothing is referencing table `t`
- CHECK(wref.e2) -- effect2 should not gc as `true` is not garbage collectable
- end
-
- gc()
- CHECK(not wref.n and not wref.e2) -- node should now gc along with effect2
- end
-
- do
- local wref
-
- do -- same test but for multiple nodes referenced by watcher
- local function factory(a, b)
- return (function(c, d)
- return function()
- return get(c), get(d)
- end
- end)(a, b)
- end
-
- local node1 = create(1)
- local node2 = create(1)
-
- do
- local effect = factory(node1, node2)
-
- wref = weak { n1 = node1, n2 = node2, e = effect }
-
- local t1 = {}
- set_effect(node1, effect, t1)
- set_effect(node2, effect, t1)
- end
-
- gc()
- CHECK(not wref.e)
- end
-
- gc()
- CHECK(not wref.n1)
- CHECK(not wref.n2)
- end
- end
end)
-TEST("source()", function()
+TEST("mount()", function()
+ local mount = vide.mount
+ local create = vide.create
local source = vide.source
- local watch = vide.watch
+ 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("root()", function()
+ local root = vide.root
+ local cleanup = vide.cleanup
+
+ local count = 0
+
+ root(function(destroy)
+ cleanup(function() count += 1 end)
+ destroy()
+ end)
+
+ CHECK(count == 1)
+end)
+
+TEST("source()", wrap_root(function()
+ local source = vide.source
+ local effect = vide.effect
do CASE "create source"
- local state = source(1)
- CHECK(state() == 1)
+ local src = source(1)
+ CHECK(src() == 1)
end
do CASE "set and get source value"
- local state = source(1)
- state(2)
- CHECK(state() == 2)
+ local src = source(1)
+ src(2)
+ CHECK(src() == 2)
end
do CASE "does not update if same value"
- local state = source(1)
+ local src = source(1)
- local updates = -1
- watch(function()
- state()
- updates += 1
+ local count = 0
+ effect(function()
+ src()
+ count += 1
end)
- CHECK(updates == 0)
- state(1)
- CHECK(updates == 0)
- state(2)
- CHECK(updates == 1)
+ CHECK(count == 1)
+ src(1)
+ CHECK(count == 1)
+ src(2)
+ CHECK(count == 2)
end
- do CASE "does update if same value is table"
- local state = source {}
+ do CASE "does update if same value is mutable table"
+ local src = source {}
- local updates = -1
- watch(function()
- state()
- updates += 1
+ local count = 0
+ effect(function()
+ src()
+ count += 1
end)
- CHECK(updates == 0)
- state(state())
- CHECK(updates == 1)
+ CHECK(count == 1)
+ src(src())
+ CHECK(count == 2)
end
do CASE "does not update if same value is frozen table"
local a = table.freeze {}
local b = table.freeze {}
- local state = source(a)
+ local src = source(a)
- local updates = -1
- watch(function()
- state()
- updates += 1
+ local count = 0
+ effect(function()
+ src()
+ count += 1
end)
- CHECK(updates == 0)
- state(a)
- CHECK(updates == 0)
- state(b)
- CHECK(updates == 1)
- state(b)
- CHECK(updates == 1)
+ CHECK(count == 1)
+ src(a)
+ CHECK(count == 1)
+ src(b)
+ CHECK(count == 2)
+ src(b)
+ CHECK(count == 2)
end
+end))
- do CASE "garbage collection of node"
- local capture = require "src/graph".capture
- local src = source(0)
-
- local wref do
- local node = unpack(capture(src))
- wref = weak { node }
- end
-
- gc()
- CHECK(not wref[1])
- end
-end)
-
-TEST("derive()", function()
+TEST("derive()", wrap_root(function()
local source = vide.source
local derive = vide.derive
+ local effect = vide.effect
+ local cleanup = vide.cleanup
do CASE "derive new value on source change"
- local inputA = source(1)
- local inputB = source(2)
+ local a = source(1)
+ local b = source(2)
- local output = derive(function()
- return tostring(inputA() + inputB())
+ local c = derive(function()
+ return tostring(a() + b())
end)
- CHECK(output() == "3")
- inputA(2)
- CHECK(output() == "4")
+ CHECK(c() == "3")
+ a(2)
+ CHECK(c() == "4")
end
do CASE "derive wrapped source"
- local input = source(1)
+ local a = source(1)
- local transform = function()
- return tostring(input())
+ local b = function()
+ return tostring(a())
end
- local output = derive(function()
- return tonumber(transform())
+ local c = derive(function()
+ return tonumber(b())
end)
- CHECK(output() == 1)
- input(2)
- CHECK(output() == 2)
+ CHECK(c() == 1)
+ a(2)
+ CHECK(c() == 2)
+ end
+
+ do CASE "does not update if same value"
+ local num = source(0)
+
+ local is_even = derive(function()
+ return bit32.band(num(), 0b01) == 0
+ end)
+
+ local count = 0
+
+ effect(function()
+ is_even()
+ count += 1
+ end)
+
+ num(1) -- odd
+ CHECK(count == 2)
+ num(2) -- even
+ CHECK(count == 3)
+ num(4) -- even
+ CHECK(count == 3)
+ num(5) -- odd
+ CHECK(count == 4)
+ end
+
+ do CASE "conditional derive"
+ local a = source(false)
+ local b = source(false)
+
+ local c = derive(function()
+ return
+ if a() then "a"
+ elseif b() then "b"
+ else "never"
+ end)
+
+ local count = 0
+
+ effect(function() c(); count += 1 end)
+
+ b(true)
+ CHECK(c() == "b")
+ CHECK(count == 2)
+ a(true)
+ CHECK(c() == "a")
+ CHECK(count == 3)
+ b(false)
+ CHECK(count == 3)
+ b(true)
+ CHECK(count == 3)
+ a(false)
+ CHECK(c() == "b")
+ CHECK(count == 4)
+ end
+
+ do CASE "owner not disconnected"
+ local count = 0
+ local a = source(0)
+
+ local destroy = vide.mount(function()
+
+ local _b = derive(function()
+ cleanup(function()
+ count += 1
+ end)
+
+ return a()
+ end)
+ end)
+
+ CHECK(count == 0)
+ a(1) -- b clears parents (should not clear owner)
+ CHECK(count == 1)
+ destroy()
+ CHECK(count == 2)
end
do CASE "garbage collection"
- do -- check that `b` does not allow gc of `a`
- local wref, b
-
- do
- local a = source(1)
-
- b = derive(function()
- return a()
- end)
+ -- check that `b` does not allow gc of `a`
+ local a = source(1)
- wref = weak { a }
- end
+ local _b = derive(function()
+ return a()
+ end)
- gc()
- CHECK(wref[1])
- b()
- end
+ _b = NIL
- do -- check that `a` allows gc of `b`
- local a = source(1)
-
- local wref
-
- do
- local b = derive(function()
- return a()
- end)
-
- wref = weak { b }
- end
-
- gc()
- CHECK(not wref[1])
- end
- end
-
- do CASE "garbage collection 2"
- -- creats a chain `a -> b -> c` where `a` is the source
- local function setup()
- local a = source(0)
-
- local b = derive(function()
- return a()
- end)
-
- local c = derive(function()
- return b()
- end)
-
- return weak { a, b, c }, a, b, c
- end
-
- do -- check that `b` and `c` can gc if `a` is referenced
- local wref, _a = setup()
-
- gc()
- CHECK(not wref[2])
- CHECK(not wref[3])
- end
-
- do -- check that `a` and `b` wont gc if `c` is referenced
- local weak, _a, _b, _c = setup()
-
- _a, _b = nil :: any, nil :: any
-
- gc()
- CHECK(weak[1])
- CHECK(weak[2])
- end
-
- do -- check that `b` wont gc if `a` and `c` are referenced
- local weak, a, _b, c = setup()
-
- _b = nil :: any
-
- gc()
- CHECK(weak[2])
-
- a(2)
- CHECK(c() == 2)
- end
- end
-
- do CASE "garbage collection of node"
- local capture = require "src/graph".capture
- local input = source(1)
-
- local wref do
- local output = derive(input)
- local output_node = unpack(capture(output))
- wref = weak { output_node }
- end
+ local wref = weak { a }
gc()
- CHECK(not wref[1])
+ CHECK(wref[1])
end
-end)
+end))
-TEST("watch()", function()
+TEST("effect()", wrap_root(function()
local source = vide.source
- local watch = vide.watch
- local cleanup = vide.cleanup
+ local effect = vide.effect
+ local derive = vide.derive
- do CASE "capture sourcess"
+ do CASE "rerun on source change"
local a = source(1)
local b = source(1)
- local runcount = -1
- watch(function()
+ local count = 0
+ effect(function()
a()
b()
- runcount += 1
+ count += 1
end)
- CHECK(runcount == 0)
+ CHECK(count == 1)
a(2)
- CHECK(runcount == 1)
+ CHECK(count == 2)
b(2)
- CHECK(runcount == 2)
+ CHECK(count == 3)
end
- do CASE "stop watch"
- local a = source(1)
+ do CASE "rerun on derived source change"
+ local num = source(0)
- local runcount = -1
- local unwatch = watch(function()
- a()
- runcount += 1
- end)
+ local text = derive(function() return tostring(num()) end)
- unwatch()
- a(2)
- CHECK(runcount == 0)
- end
-
- do CASE "side-effect cleanup"
- local state = source(1)
-
- local effect_runcount = 0
- local cleanup_runcount = 0
-
- local unwatch = watch(function()
- state()
- effect_runcount += 1
- cleanup(function() cleanup_runcount += 1 end)
+ local count = 0
+ effect(function()
+ text()
+ count += 1
end)
- CHECK(effect_runcount == 1)
- CHECK(cleanup_runcount == 0)
- state(2)
- CHECK(effect_runcount == 2)
- CHECK(cleanup_runcount == 1)
+ num(1)
- unwatch()
- unwatch = nil :: any
- gc()
- vide.step(0)
-
- CHECK(effect_runcount == 2)
- CHECK(cleanup_runcount == 2)
+ CHECK(count == 2)
end
- do CASE "garbage collection"
- local function factory(p)
- return function()
- p()
- end
- end
+ do CASE "cache"
+ local num = source(0)
- do -- state prevents gc of watcher
- local state = source(1)
+ local count
- local wref
+ effect(function(x: number)
+ num()
+ count = x + 1
+ return x + 1
+ end, 0)
- do
- local effect = factory(state)
- watch(effect)
- wref = { effect }
- end
+ num(1)
- gc()
- CHECK(wref[1])
- end
-
- do -- watcher can gc if stopped
- local state = source(1)
-
- local wref, unwatch
-
- do
- local effect = factory(state)
- unwatch = watch(effect)
- wref = weak { effect }
- end
-
- gc()
- CHECK(wref[1])
-
- unwatch()
- unwatch = nil :: any -- unwatch holds ref to effect
-
- gc()
- CHECK(not wref[1])
-
- end
-
- do -- state can gc with watcher
- local wref
-
- do
- local state = source(1)
- local effect = factory(state)
- watch(effect)
- wref = weak { state }
- end
-
- gc()
- CHECK(not wref[1])
- end
+ CHECK(count == 2)
end
-end)
+end))
-TEST("cleanup()", function()
+TEST("cleanup()", wrap_root(function()
local source = vide.source
- local watch = vide.watch
+ local effect = vide.effect
local cleanup = vide.cleanup
- do CASE "cleanup runs for watcher"
- local state = source(1)
+ do CASE "root cleanup"
+ local count = 0
- local watched = 0
+ local destroy = vide.mount(function()
+ cleanup(function()
+ count += 1
+ end)
+ end)
+
+ CHECK(count == 0)
+ destroy()
+ CHECK(count == 1)
+ end
+
+ do CASE "cleanup on rerun"
+ local src = source(1)
+
+ local effected = 0
local cleaned = 0
- local stop = watch(function()
- state()
- watched += 1
+ effect(function()
+ src()
+ effected += 1
cleanup(function()
cleaned += 1
end)
end)
- CHECK(watched == 1)
+ CHECK(effected == 1)
CHECK(cleaned == 0)
- state(2)
+ src(2)
- CHECK(watched == 2)
+ CHECK(effected == 2)
CHECK(cleaned == 1)
-
- stop()
-
- do -- vide detects by iterating through and checking for gc'd refs
- stop = nil :: any
- gc()
- vide.step(0)
- end
-
- CHECK(watched == 2)
- CHECK(cleaned == 2)
end
- do CASE "scoped"
- local function setup()
- local state = source(1)
- local obj = { cleaned = 0 }
-
- local _stop = watch(function()
- state()
- cleanup(function()
- obj.cleaned += 1
- end)
- end)
-
- return state, obj
- end
-
- local stateA, objA = setup()
- local stateB, objB = setup()
-
- CHECK(objA.cleaned == 0)
- CHECK(objB.cleaned == 0)
-
- stateA(2)
-
- CHECK(objA.cleaned == 1)
- CHECK(objB.cleaned == 0)
-
- stateB(2)
-
- CHECK(objA.cleaned == 1)
- CHECK(objB.cleaned == 1)
-
- do
- stateA = nil :: any
- gc()
- vide.step(0)
- end
-
- CHECK(objA.cleaned == 2)
- CHECK(objB.cleaned == 1)
-
- do
- stateB = nil :: any
- gc()
- vide.step(0)
- end
-
- CHECK(objA.cleaned == 2)
- CHECK(objB.cleaned == 2)
- end
-
- -- this is not allowed, test to verify behavior anyways
do CASE "multiple cleanup"
- local state = source(1)
+ local src = source(1)
local queue = {}
- watch(function()
- state()
- cleanup(function() table.insert(queue, 1) end)
- cleanup(function() table.insert(queue, 2) end)
- end)
-
- CHECK(testkit.seq(queue, { 1 }))
- state(2)
- CHECK(testkit.seq(queue, { 1, 2, 1 }))
- state(3)
- CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1 }))
- end
-
- --[[
- do CASE "multiple cleanup"
- local state = source(1)
-
- local queue = {}
-
- watch(function()
- state()
+ effect(function()
+ 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 }))
-
- do
- state = nil :: any
- gc()
- vide.step(0)
- end
-
- -- todo: guarantee call order when gc? (currently not)
- --testkit.print2(queue)
- --CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1, 2 }))
end
- ]]
-end)
+end))
-TEST("create()", function()
+TEST("create()", wrap_root(function()
local create = vide.create
local source = vide.source
+ local cleanup = vide.cleanup
do CASE "apply default properties"
local defaults = require("src/defaults")
@@ -673,9 +665,7 @@ TEST("create()", function()
{
Text = "1",
- {
- Text = "2"
- }
+ { Text = "2" }
}
}
@@ -709,7 +699,6 @@ TEST("create()", function()
CHECK(frame:FindFirstChild "C")
CHECK(frame:FindFirstChild "D")
CHECK(frame:FindFirstChild "E")
-
CHECK(frame:FindFirstChild "F")
CHECK(frame:FindFirstChild "G")
end
@@ -733,119 +722,38 @@ TEST("create()", function()
CHECK(label.Text == "Bar")
end
- do CASE "binding garbage collection"
- do -- instance should gc when unparented
- local state = source("Hi")
+ do CASE "binding destroy"
+ local count = 0
- local wref = weak {
- create "TextLabel" {
- Text = state,
- }
+ local destroy = vide.mount(function()
+ local src = source(0)
+
+ return create "TextLabel" {
+ Text = function()
+ cleanup(function()
+ count += 1
+ end)
+
+ return src()
+ end
}
+ end)
- gc()
- CHECK(not wref[1])
- end
-
- do -- instance should not gc when parented
- local state = source("Hi")
-
- local parent = create "Frame" {}
-
- local wref = weak {
- create "TextLabel" {
- Parent = parent,
- Text = state,
- }
- }
-
- gc()
- CHECK(wref[1])
-
- wref[1].Parent = nil
- wref[1].Parent = parent
-
- gc()
- CHECK(wref[1])
-
- wref[1]:Destroy()
-
- gc()
- CHECK(not wref[1])
- end
-
- do -- instance does not allow gc of state
- local label
- local wref
-
- do
- local state = source("Hi")
- label = create "TextLabel" {
- Name = state,
- }
- wref = weak { state :: any, label }
-
- end
-
- gc()
- CHECK(wref[2])
- CHECK(wref[1])
- end
-
- do -- state and instance should gc once both exit scope
- local wref
-
- do
- local text = source("Hi")
-
- local box = create "TextLabel" {
- Text = text,
- }
-
- wref = weak { text = text, box = box}
- end
-
- gc()
- CHECK(not wref.text)
- CHECK(not wref.box)
- end
-
- do -- binding should gc despite state still existing after instance is gc
- local state = source("Hi")
-
- local node = require "src/graph".capture(state)[1]
-
- local wref
-
- do
- local instance = create "TextLabel" {
- Text = state,
- }
-
- wref = weak {
- instance = instance,
- binding = next(node.effects)
- }
- end
-
- CHECK(wref.binding)
-
- gc()
- CHECK(not wref.instance)
- CHECK(not wref.binding)
- end
+ CHECK(count == 0)
+ destroy()
+ CHECK(count == 1)
end
- do CASE "bind same state to multiple instance properties"
- local state = source "1"
+ do CASE "bind same source to multiple instance properties"
+ local src = source "1"
local text = create "TextBox" {
- Name = state,
- Text = state,
- PlaceholderText = state
+ Name = src,
+ Text = src,
+ PlaceholderText = src
}
- state "2"
+ src "2"
CHECK(text.Name == "2")
CHECK(text.Text == "2")
@@ -853,7 +761,7 @@ TEST("create()", function()
end
do CASE "bind children"
- local state = source()
+ local children = source()
local a, b, c =
create "TextLabel" { Name = "A" },
@@ -861,50 +769,53 @@ TEST("create()", function()
create "TextLabel" { Name = "C" }
local frame = create "Frame" {
- state
+ children
}
- state { a, b }
+ children { a, b }
CHECK(frame:FindFirstChild "A")
CHECK(frame:FindFirstChild "B")
-- check that b is removed and c is added while a remains untouched
- state { a, c }
+ children { a, c }
CHECK(frame:FindFirstChild "A")
CHECK(frame:FindFirstChild "C")
CHECK(not frame:FindFirstChild "B")
- state(nil)
+ children(nil)
CHECK(#frame:GetChildren() == 0)
end
- --[[
- do CASE "Parent set to nil by state does not allow gc"
- local frame = create "Frame" { Name = "Parent" }
- local parent = source(frame :: Frame?)
+ do CASE "parent bound to source"
+ local wref, destroy = vide.root(function(destroy)
+ local frame = create "Frame" { Name = "Parent" }
+ local parent = source(frame :: Frame?)
- local wref = weak {
- create "TextLabel" { Parent = parent, Name = "Child" }
- }
+ local wref = weak {
+ create "TextLabel" { Parent = parent, Name = "Child" }
+ }
+
+ gc()
+ CHECK(wref[1])
+
+ parent(nil)
+
+ return wref, destroy
+ end)
gc()
CHECK(wref[1])
- parent(nil)
-
- gc()
- CHECK(wref[1])
-
- wref[1]:Destroy()
+ destroy()
+ destroy = NIL
gc()
CHECK(not wref[1])
end
- ]]
do CASE "garbage collection test"
local wref
@@ -931,11 +842,119 @@ TEST("create()", function()
gc()
CHECK(wref.data and wref.proxy)
end
-end)
+end))
--- todo: gc and cleanup call check for removed element
+TEST("switch()", wrap_root(function()
+ local source = vide.source
+ local switch = vide.switch
+ local effect = vide.effect
+ local cleanup = vide.cleanup
-TEST("indexes()", function()
+ do CASE "update on source change"
+ local input = source(true)
+
+ local output = switch(input) {
+ [true] = function() return 1 end,
+ [false] = function() return 0 end
+ }
+
+ local count = 0
+
+ effect(function() output(); count += 1 end)
+
+ CHECK(count == 1)
+ CHECK(output() == 1)
+
+ input(false)
+ CHECK(output() == 0)
+ CHECK(count == 2)
+
+ input(false)
+ CHECK(output() == 0)
+ CHECK(count == 2)
+
+ 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)
+
+ local owner_count = 0
+ local switch0_count = 0
+ local switch1_count = 0
+
+ cleanup(function() owner_count += 1 end)
+
+ local output = switch(input) {
+ [true] = function()
+ cleanup(function() switch1_count += 1 end)
+ return 1
+ end,
+
+ [false] = function()
+ cleanup(function() switch0_count += 1 end)
+ return 0
+ end
+ }
+
+ CHECK(output() == 1)
+ input(false)
+ CHECK(switch1_count == 1)
+ CHECK(switch0_count == 0)
+ input(true)
+ CHECK(switch1_count == 1)
+ CHECK(switch0_count == 1)
+ 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()
local create = vide.create
local source = vide.source
local indexes = vide.indexes
@@ -956,10 +975,10 @@ TEST("indexes()", 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)
@@ -969,9 +988,9 @@ TEST("indexes()", 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"
@@ -997,7 +1016,7 @@ TEST("indexes()", function()
CHECK(t[1].Text == "1")
CHECK(t[2].Text == "2")
- CHECK(t[3] == nil :: any)
+ CHECK(t[3] == NIL)
CHECK(destroyed == true)
end
@@ -1011,7 +1030,7 @@ TEST("indexes()", function()
local wref = weak { input }
- input = nil :: any
+ input = NIL
gc()
CHECK(wref[1])
@@ -1026,7 +1045,7 @@ TEST("indexes()", function()
local wref = weak { output }
- output = nil :: any
+ output = NIL
gc()
CHECK(not wref[1])
@@ -1037,21 +1056,8 @@ TEST("indexes()", function()
local input = source { 1, 2, 3 }
local count = table.create(3, 0)
- local unrelated_count = 0
-
- local unrelated = (function()
- return function()
- cleanup(function()
- unrelated_count += 1
- end)
- end
- end)()
local output = indexes(input, function(v, i)
- -- check that overriden cleanup scopes don't affect cleanup calls
- -- in other function scopes
- unrelated()
-
cleanup(function()
count[i] += 1
end)
@@ -1064,20 +1070,31 @@ TEST("indexes()", function()
CHECK(count[1] == 0)
CHECK(count[2] == 0)
CHECK(count[3] == 0)
- CHECK(unrelated_count == 2)
-
- output = nil :: any
- gc()
- vide.step(0)
-
- CHECK(count[1] == 1)
- CHECK(count[2] == 1)
- CHECK(count[3] == 1)
- CHECK(unrelated_count == 2)
end
-end)
-TEST("values()", function()
+ 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()
local create = vide.create
local source = vide.source
local values = vide.values
@@ -1098,10 +1115,10 @@ TEST("values()", 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)
@@ -1111,9 +1128,9 @@ TEST("values()", 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"
@@ -1139,7 +1156,7 @@ TEST("values()", function()
CHECK(t[1].Text == "1")
CHECK(t[2].Text == "2")
- CHECK(t[3] == nil :: any)
+ CHECK(t[3] == NIL)
CHECK(destroyed == true)
end
@@ -1165,21 +1182,8 @@ TEST("values()", function()
local input = source { 1, 2, 3 }
local count = table.create(3, 0)
- local unrelated_count = 0
-
- local unrelated = (function()
- return function()
- cleanup(function()
- unrelated_count += 1
- end)
- end
- end)()
local output = values(input, function(v, i)
- -- check that overriden cleanup scopes don't affect cleanup calls
- -- in other function scopes
- unrelated()
-
cleanup(function()
count[i()] += 1
end)
@@ -1192,75 +1196,87 @@ TEST("values()", function()
CHECK(count[1] == 0)
CHECK(count[2] == 0)
CHECK(count[3] == 0)
- CHECK(unrelated_count == 2)
-
- output = nil :: any
- gc()
- vide.step(0)
-
- CHECK(count[1] == 1)
- CHECK(count[2] == 1)
- CHECK(count[3] == 1)
- CHECK(unrelated_count == 2)
end
-end)
-TEST("spring()", function()
+ 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()
local create = vide.create
local source = vide.source
local spring = vide.spring
- local watch = vide.watch
+ local effect = vide.effect
do CASE "update source (on next step)"
local value = source(10)
- local springed = spring(value, 1, 1)
+ local sprung = spring(value, 1, 1)
+ CHECK(sprung() == 10)
value(20)
- CHECK(springed() == 10)
+ CHECK(sprung() == 10)
vide.step(1/60)
- CHECK(springed() ~= 10)
- CHECK(springed() > 10)
+ CHECK(sprung() ~= 10)
+ CHECK(sprung() > 10)
end
do CASE "garbage collection"
+ --[[
do -- `output` should not allow gc of `input`
local input = source(10)
local _output = spring(input)
local wref = weak { input }
- input = nil :: any
+ input = NIL
gc()
CHECK(wref[1])
end
+ ]]
do -- `input` should allow gc of `output`
local input = source(10)
local output = spring(input)
local wref = weak { output }
- output = nil :: any
+ output = NIL
gc()
CHECK(not wref[1])
end
- do -- spring data gc
- local capture = require "src/graph".capture
+ -- do -- spring data gc
+ -- local input = source(10)
- local input = source(10)
-
- local wref do
- local output, data = (spring :: any)(input)
- input(input() + 1) -- schedule spring calculation
- local output_node = unpack(capture(output))
- wref = weak { output_node, data }
- end
+ -- local wref do
+ -- local output, data = (spring :: any)(input)
+ -- input(input() + 1) -- schedule spring calculation
+ -- local output_node = unpack(capture(output))
+ -- wref = weak { output_node, data }
+ -- end
- gc()
- CHECK(not wref[1])
- CHECK(not wref[2])
- end
+ -- gc()
+ -- CHECK(not wref[1])
+ -- CHECK(not wref[2])
+ -- end
end
do CASE "garbage collection (binded)"
@@ -1272,7 +1288,7 @@ TEST("spring()", function()
}
local wref = { output }
- output = nil :: any
+ output = NIL
gc()
CHECK(wref[1]) -- `output` should not gc
@@ -1289,7 +1305,7 @@ TEST("spring()", function()
CHECK(output() == input()) -- check spring is at target
local count = -1
- watch(function()
+ effect(function()
output()
count += 1
end)
@@ -1303,35 +1319,38 @@ TEST("spring()", function()
vide.step(0) -- process spring queue
CHECK(count == 1) -- check spring was rescheduled correctly
end
-end)
+end))
-TEST("untrack()", function()
+TEST("untrack()", wrap_root(function()
+ local root = vide.root
local source = vide.source
- local watch = vide.watch
+ local derive = vide.derive
+ local effect = vide.effect
+ local cleanup = vide.cleanup
local untrack = vide.untrack
do CASE "does not register dependency"
local a = source(0)
local b = source(0)
- local count = -1
+ local count = 0
- watch(function()
+ effect(function()
count += 1
untrack(a)
b()
end)
b(1)
- CHECK(count == 1)
+ CHECK(count == 2)
a(1)
- CHECK(count == 1)
+ CHECK(count == 2)
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)
@@ -1340,22 +1359,74 @@ TEST("untrack()", function()
return a() + b()
end
- local count = -1
+ local count = 0
- watch(function()
+ effect(function()
count += 1
untrack(d)
c()
end)
c(1)
- CHECK(count == 1)
+ CHECK(count == 2)
a(1)
b(1)
- CHECK(count == 1)
+ CHECK(count == 2)
end
-end)
+
+ do CASE "outer scope"
+ local outer_count = 0
+ local inner_count = 0
+ local cleaned_count = 0
+
+ local input = source(0)
+
+ local output, destroy = root(function(destroy)
+ local output = derive(function()
+ outer_count += 1
+
+ return untrack(function()
+ return derive(function()
+ inner_count += 1
+
+ cleanup(function()
+ cleaned_count += 1
+ end)
+
+ return tostring(input())
+ end)
+ end)
+ end)
+
+ return output, destroy
+ end)
+
+ CHECK(outer_count == 1)
+ CHECK(inner_count == 1)
+ CHECK(cleaned_count == 0)
+
+ local output2 = output()
+
+ CHECK(output2() == "0")
+
+ input(1)
+
+ CHECK(outer_count == 1)
+ CHECK(inner_count == 2)
+ CHECK(cleaned_count == 1)
+
+ local output3 = output()
+ CHECK(output2() == "1")
+ CHECK(output3() == "1")
+
+ CHECK(output2 == output3)
+
+ destroy()
+
+ CHECK(cleaned_count == 2)
+ end
+end))
TEST("events", function()
local create = vide.create
@@ -1421,36 +1492,74 @@ TEST("actions", function()
end
end)
-TEST("strict", function()
+TEST("changed()", wrap_root(function()
+ local root = vide.root
+ local create = vide.create
+ local source = vide.source
+ local changed = vide.changed
+
+ do CASE "outputs"
+ local output = source(nil)
+
+ local text = create "TextLabel" {
+ Text = "a",
+ changed("Text", output)
+ }
+
+ --CHECK(output() == "a")
+ text.Text = "b"
+ CHECK(output() == "b")
+ end
+
+ do CASE "connection disconnected"
+ local text, destroy = root(function(destroy)
+ local output = source(nil)
+
+ return create "TextLabel" {
+ Text = "a",
+ changed("Text", output)
+ }, destroy
+ end)
+
+ destroy() -- changed() should of disconnect connection
+
+ -- check if instance can gc
+ local wref = weak { text }
+ text = NIL
+ gc()
+ CHECK(not wref[1])
+ end
+end))
+
+TEST("strict", wrap_root(function()
vide.strict = true
local create = vide.create
local source = vide.source
local derive = vide.derive
- local watch = vide.watch
+ local effect = vide.effect
local indexes, values = vide.indexes, vide.values
- local cleanup = vide.cleanup
do CASE "error on derived callback yield"
- local state = source(1)
+ local src = source(1)
local ok = pcall(function()
local _derived = derive(function()
coroutine.yield()
- return state()
+ return src()
end)
end)
CHECK(not ok)
end
- do CASE "error on watcher callback yield"
- local state = source(1)
+ do CASE "error on effecter callback yield"
+ local src = source(1)
local ok = pcall(function()
- local _derived = watch(function()
+ effect(function()
coroutine.yield()
- state()
+ src()
end)
end)
@@ -1458,48 +1567,48 @@ TEST("strict", function()
end
do CASE "run derived callback twice"
- local state = source(1)
- local runcount = 0
+ local src = source(1)
+ local count = 0
local _ = derive(function()
- runcount += 1
- return state()
+ count += 1
+ return src()
end)
- CHECK(runcount == 2)
- state(2)
- CHECK(runcount == 4)
+ CHECK(count == 2)
+ src(2)
+ CHECK(count == 4)
end
- do CASE "run watcher callback twice"
- local state = source(1)
- local runcount = 0
+ do CASE "run effect callback twice"
+ local src = source(1)
+ local count = 0
- watch(function()
- runcount += 1
- state()
+ effect(function()
+ count += 1
+ src()
end)
- CHECK(runcount == 2)
- state(2)
- CHECK(runcount == 4)
+ CHECK(count == 2)
+ src(2)
+ CHECK(count == 4)
end
do CASE "indexes() error if primitive"
- local state = source { 1 }
+ local src = source { 1 }
local ok = pcall(function()
- indexes(state, function() return 1 end)
+ indexes(src, function() return 1 end)
end)
CHECK(not ok)
end
do CASE "values() error if duplicate"
- local state = source { 1, 2, 1 }
+ local src = source { 1, 2, 1 }
local ok = pcall(function()
- values(state, function() return {} end)
+ values(src, function() return {} end)
end)
CHECK(not ok)
@@ -1532,16 +1641,7 @@ TEST("strict", function()
CHECK(ok)
end
-
- do CASE "multiple cleanup per scope"
- local ok = pcall(function()
- cleanup(function() end)
- cleanup(function() end)
- end)
-
- CHECK(not ok)
- end
-end)
+end))
local ok = FINISH()
if not ok then error("Tests failed", 0) end
diff --git a/todo.md b/todo.md
index 250bfb3..0d59b74 100644
--- a/todo.md
+++ b/todo.md
@@ -1,23 +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
-- have derived sources/bindings track sources dynamically?
- - solves case where sources are used in if-branching guarded by another
- source
- - significantly reduces performance
-- solution to component cleanup
- - rely on `Instance.Destroying` event and manual destruction when cleanup is
- needed?
- - expand behavior of `vide.cleanup()` to detect garbage collection of
- arbitrary values, not needing manual destruction
- - look into SolidJS's reactive contexts
+- property binding optimization
+ - would no longer allow `cleanup()` usage in binding scopes
- solution to nested reactivity, see: SolidJS stores
-- SolidJS control flow components
- - Show
- - Switch
- - Dynamic
+- investigate performance of wide graphs
+- optimize child removal
+- implement from solid:
- Portal
-- batch source updates
+ - batch
- optimize `indexes()` double-diffing
+- define behavior of deriving a source within a derived source