diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21d7d4d..83d16e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
-## Unreleased
+--------------------------------------------------------------------------------
----
+## [0.2.0] - 2023-11-22
-## [0.1.0] - 0000-00-00
+### Added
+
+- Batched updates with `batch()`.
+
+### Changed
+
+- Improved graph updating algorithm.
+- Graph nodes when destroyed no longer destroy children; only owned.
+
+### Fixed
+
+- Graph edge case where a destroyed node can be readded if it was queued for
+ rerun before being destroyed.
+- Some properties not being applied when `create()` is used recursively.
+
+--------------------------------------------------------------------------------
+
+## [0.1.1] - 2023-09-30
+
+### Added
+
+- `cleanup()` accepts objects with a `Destroy()` or `Disconnect()` interface.
+- `read()` as a utility to read sources or passthrough a non-source value.
+
+### Changed
+
+- Reactive scopes created within reactive scopes are now destroyed on rerun.
+- `untrack()` can be called outside of reactive scopes.
+- `changed()` will also run its callback with the initial property value.
+
+### Fixed
+
+- `show()` and `switch()` not updating when in strict mode.
+
+--------------------------------------------------------------------------------
+
+## [0.1.0] - 2023-09-20
- Initial release
diff --git a/README.md b/README.md
index 2901dbe..8ce077b 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,13 @@
-
+
-
-
-### ⚠️ This library is in early stages of development with breaking changes being made often.
-
-Vide is a reactive UI library.
+Vide is a reactive Luau UI library inspired by [Solid](https://www.solidjs.com/).
- Fully Luau typecheckable
- Declarative and concise syntax.
-- Minimal imports.
- Reactively driven.
## Getting started
@@ -24,7 +19,6 @@ for a quick introduction to the library.
## Code sample
```lua
-local vide = require(path_to_vide)
local create = vide.create
local source = vide.source
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 519d4d6..87dada1 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -1,7 +1,8 @@
-import { defineConfig } from "vitepress"
+//import { defineConfig } from "vitepress"
+import { withMermaid } from "vitepress-plugin-mermaid";
// https://vitepress.dev/reference/site-config
-export default defineConfig({
+export default withMermaid({
title: "Vide",
titleTemplate: ":title - A reactive UI library for Luau",
description: "A reactive UI library for Luau.",
@@ -16,7 +17,6 @@ export default defineConfig({
{ text: "Home", link: "/" },
{ text: "Tutorials", link: "/tut/crash-course/1-introduction" },
{ text: "API", link: "/api/reactivity-core"},
- { text: "GitHub", link: "https://github.com/centau/vide" }
],
sidebar: {
@@ -41,34 +41,31 @@ export default defineConfig({
{ text: "Introduction", link: "/tut/crash-course/1-introduction" },
{ 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: "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: "Sources", link: "/tut/crash-course/4-source" },
+ { text: "Effects", link: "/tut/crash-course/5-effect" },
+ { text: "Root Scopes", link: "/tut/crash-course/6-root" },
+ { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" },
+ { text: "Property Binding", link: "/tut/crash-course/8-property-binding" },
+ { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" },
+ { text: "Cleanup", link: "/tut/crash-course/10-cleanup" },
+ { text: "Control Flow", link: "/tut/crash-course/11-control-flow" },
+ { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" },
+ { text: "Actions", link: "/tut/crash-course/13-actions" },
+ { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" },
+ { text: "Concepts Summary", link: "/tut/crash-course/15-concepts" }
]
},
{
- text: "Control Flow WIP",
+ text: "Advanced Reactivity",
items: [
- { 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"}
+ { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"}
]
}
],
- }
+ },
- // socialLinks: [
- // { icon: "github", link: "https://github.com/centau/vide" }
- // ]
+ socialLinks: [
+ { icon: "github", link: "https://github.com/centau/vide" }
+ ]
}
})
diff --git a/docs/api/creation.md b/docs/api/creation.md
index f5b4c1c..8e67478 100644
--- a/docs/api/creation.md
+++ b/docs/api/creation.md
@@ -4,7 +4,8 @@
## mount()
-Runs a function and applies its result to a target instance.
+Runs a function in a new reactive scope and optionally applies its result to a
+target instance.
- **Type**
@@ -14,7 +15,7 @@ Runs a function and applies its result to a target instance.
- **Details**
- The result of the function is applies to the target in the same way
+ The result of the function is applied to a target in the same way
properties are using `create()`.
The function is ran in a new reactive scope, just like
@@ -60,19 +61,16 @@ Creates a new UI element, applying any given properties.
- **Property setting rules**
- - If a table index is a string:
- - 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 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.
+ - **index is string:**
+ - **value is function:**
+ - **property is event:** connect function as callback
+ - **property is not event:** create effect to update property
+ - **value is not function:** set property to value
+ - **index is number:**
+ - **value is action:** run action
+ - **value is table:** recurse table
+ - **value is function:** create effect to update children
+ - **value is instance:** set instance as child
- **Example**
@@ -138,9 +136,14 @@ instances.
```lua
local function changed(property: string, callback: (new) -> ())
return action(function(instance)
- instance:GetPropertyChangedSignal("property"):Connect(function()
+ local con - instance:GetPropertyChangedSignal(property):Connect(function()
callback(instance[property])
end)
+
+ -- disconnect on reactive scope destruction to allow gc of instance
+ cleanup(function()
+ con:Disconnect()
+ end)
end)
end
@@ -151,3 +154,23 @@ instances.
changed("Text", output)
}
```
+
+## changed()
+
+A wrapper for `action()` to listen for property changes.
+
+- **Type**
+
+ ```lua
+ function changed(property: string, callback: (...unknown) -> ()): Action
+ ```
+
+- **Details**
+
+ Will run the given callback any time the property is changed, as well as
+ when the action is initially run.
+
+ The changed connection is disconnected when the reactive scope the action is
+ ran in is destroyed.
+
+ Runs with an action priority of 1.
diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md
index 0e4a5d0..133bc4a 100644
--- a/docs/api/reactivity-core.md
+++ b/docs/api/reactivity-core.md
@@ -2,6 +2,10 @@
+:::warning
+Yielding is not allowed in any reactive scope. Strict mode can check for this.
+:::
+
## root()
Creates and runs a function in a new reactive scope.
@@ -14,18 +18,14 @@ Creates and runs a function in a new reactive scope.
- **Details**
+ Returns the result of the given function.
+
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.
@@ -44,6 +44,8 @@ Creates a new source with the given value.
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.
+ Sources can be created outside of reactive scopes.
+
- **Example**
```lua
@@ -56,7 +58,7 @@ Creates a new source with the given value.
## effect()
-Runs a side-effect on source update.
+Runs a side-effect in a new reactive scope on source update.
- **Type**
@@ -66,14 +68,10 @@ Runs a side-effect on source update.
- **Details**
- The callback is ran immediately.
-
Any time a source referenced in the callback is changed, the callback will
be reran.
- ::: warning
- `callback()` cannot yield.
- :::
+ The callback is ran to initially ran on first call to find dependent sources.
- **Example**
@@ -93,7 +91,7 @@ Runs a side-effect on source update.
## derive()
-Derives a new source from existing sources.
+Derives a new source in a new reactive scope from existing sources.
- **Type**
@@ -109,12 +107,7 @@ Derives a new source from existing sources.
Anytime its value is recalculated it is also cached, subsequent calls will
retun this cached value until it recalculates again.
- Takes a callback that is immediately run to determine what sources are being
- referenced.
-
- ::: warning
- `source()` cannot yield.
- :::
+ The callback is ran to initially ran on first call to find dependent sources.
- **Example**
diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md
index 58e2200..47dafa8 100644
--- a/docs/api/reactivity-flow.md
+++ b/docs/api/reactivity-flow.md
@@ -2,9 +2,33 @@
+## show()
+
+Shows one of two components depending on an input source.
+
+- **Type**
+
+ ```lua
+ function show(source: () -> unknown, component: () -> T): () -> T?
+ function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U
+ ```
+
+- **Details**
+
+ Returns a source holding an instance of the currently shown component.
+
+ When the input source changes from a falsey to a truthy value, the
+ component will be reran under a new reactive scope. If it changes from a
+ truthy to falsey value, the reactive scope the component was created in will
+ be destroyed, and the returned source will output `nil`, or a fallback
+ component if given.
+
+ The fallback component is also ran under a new reactive scope, and destroyed
+ when the input source switches back to truthy.
+
## switch()
-Changes object based on a source and a mapping table.
+Shows one of a set of components depending on an input source and a mapping table.
- **Type**
@@ -14,12 +38,14 @@ Changes object based on a source and a mapping table.
- **Details**
- The mapped function is ran in a new reactive scope that is destroyed when
- the source changes and maps to a different function.
+ Returns a source holding an instance of the currently shown component.
- ::: warning
- Mapped functions cannot yield.
- :::
+ When the input source changes, the new value will be used to lookup a given
+ mapping table to get a component, which will be ran under a new reactive
+ scope. If the input source changes, the reactive scope the component was
+ created in will be destroyed, and a new component created under a new
+ reactive scope. If no component is found for an input value, the switch will
+ output `nil`.
- **Example**
@@ -51,24 +77,26 @@ Maps each index in a table source to an object.
- **Details**
+ Returns a source holding an array of instances currently shown.
+
+ When the input source changes, each *index* in the new table is compared with
+ the last input table.
+
+ - For any new index, the `transform` function is ran under a new reactive
+ scope to produce a new instance.
+ - For any removed index, the reactive scope for that index is destroyed.
+ - Unchanged indexes are untouched.
+
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.
+ source table.
- Anytime a new index is added, the transform function will be called again
- for that new index.
+ 1. First argument is a *source containing the index's value*.
+ 2. Second argument is the *index itself*.
- Anytime an existing index value changes, the transform function is not rerun,
- instead the source value for that index will update, causing anything
+ Anytime an existing index's 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
@@ -85,14 +113,12 @@ Maps each index in a table source to an object.
local displays = indexes(items, function(item, i)
return ItemDisplay {
Name = function()
- return item().name
+ return i .. ": " .. item().name
end,
Image = function()
return "rbxassetid://" .. item().icon
end,
-
- LayoutOrder = i
}
end)
```
@@ -111,28 +137,31 @@ Maps each value in a table source to an object.
- **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.
+ Returns a source holding an array of instances currently shown.
- Anytime a new value is added, the transform function will be called again
- for that new value.
+ When the input source changes, each *value* in the new table is compared with
+ the last input table. Similar to `indexes()` but for values instead of indexes.
+
+ - For any new value, the `transform` function is ran under a new reactive
+ scope to produce a new instance.
+ - For any removed value, the reactive scope for that value is destroyed.
+ - Unchanged values are untouched.
+
+ The transform function is only ever called *once* for each value in the
+ source table.
+
+ 1. First argument is the *value itself*.
+ 2. Second argument is a *source containing the value's index*.
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.
+ Having primitive values in the input source table can cause unexpected
+ behavior, as duplicate values can result in multiple tranforms being ran for
+ a single value, meaning there can be multiple source indexes bound to the
+ same UI element. Strict mode has checks for this.
:::
- **Example**
@@ -150,11 +179,11 @@ Maps each value in a table source to an object.
local displays = values(items, function(item, i)
return ItemDisplay {
- Name = item.Name
+ Name = function()
+ return i() .. ": " .. item.Name
+ end
Image = "rbxassetid://" .. item.icon,
-
- LayoutOrder = i
}
end)
```
@@ -181,6 +210,9 @@ Maps each value in a table source to an object.
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.
+ result in less property updates and less re-renders. One case to note is
+ that `values()` works nicely when animating re-ordering of instances, since
+ the value is not destroyed when indexes are changed, and the source index
+ can easily be put through a spring.
--------------------------------------------------------------------------------
diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md
index 22adba5..ee82067 100644
--- a/docs/api/reactivity-utility.md
+++ b/docs/api/reactivity-utility.md
@@ -2,12 +2,17 @@
## cleanup()
-Runs a callback anytime a reactive scope is re-ran.
+Runs a callback anytime a reactive scope is reran or destroyed.
- **Type**
```lua
function cleanup(callback: () -> ())
+ function cleanup(obj: Destroyable)
+ function cleanup(obj: Disconnectable)
+
+ type Destroyable = { destroy: () -> () }
+ type Disconnectable = { disconnect: () -> () }
```
- **Example**
@@ -24,23 +29,10 @@ Runs a callback anytime a reactive scope is re-ran.
end)
```
- ```lua
- local data = source(1)
-
- derive(function()
- local label = create "TextLabel" { Text = data() }
-
- cleanup(function()
- label:Destroy()
- end)
-
- return label
- end)
- ```
-
## untrack()
-Runs a given function where any sources read will not track its reactive scope.
+Runs a given function where any sources read will not be tracked by a reactive
+scope.
- **Type**
@@ -70,4 +62,33 @@ Runs a given function where any sources read will not track its reactive scope.
print(sum()) -- 2
```
+## read()
+
+Utility used to read a value that is either a primitive or a source. Sources
+read can still be tracked inside a reactive scope.
+
+- **Type**
+
+ ```lua
+ function read(value: T | () -> T): T
+ ```
+
+## batch()
+
+Runs a given function where any source updates made within the function do not
+trigger effects until after the function runs.
+
+- **Type**
+
+ ```lua
+ function batch(fn: () -> ())
+ ```
+
+- **Details**
+
+ Improves performance when an effect depends on multiple sources, and those
+ sources need to be updated. Updating those sources inside a batch call will
+ only cause the effect to run once after the batch call ends instead of after
+ each time a source is updated.
+
--------------------------------------------------------------------------------
diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md
index 0472954..a7fca48 100644
--- a/docs/api/strict-mode.md
+++ b/docs/api/strict-mode.md
@@ -6,6 +6,9 @@ Strict mode is library-wide and can get set by doing:
vide.strict = true
```
+It is automatically enabled when Vide is first required and not running in O2
+optimization level.
+
Strict mode is designed to help the development process by adding safety checks
and identifying improper usage.
@@ -15,12 +18,13 @@ Currently, strict mode will:
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.
+5. Checks for `values()` input having duplicate values.
+6. Checks for duplicate nested properties at same depth.
+7. Better error reporting and stack traces + creation traces of property bindings.
-By rerunning sources and effects, any side-effects are made more apparent.
-This also helps ensure that cleanups are being handled correctly.
+By rerunning derived sources and effects twice each time they update,it helps
+ensure that derived source computations are pure, and that any
+cleanups made in derived sources or effects are done correctly.
Accidental yielding within reactive scopes can break Vide's reactive graph,
which strict mode can catch.
@@ -29,5 +33,6 @@ As well as additional safety checks, Vide will dedicate extra resources to
recording and better emitting stack traces where errors occur, particularly
when binding properties to sources.
-It is recommend to develop UI with strict mode and to disable it when pushing to
-production.
+It is recommended to develop UI with strict mode and to disable it when pushing to
+production. In Roblox, production code compiles at O2 by default, so you don't
+need to worry about disabling strict mode unless you have manually enabled it.
diff --git a/docs/package.json b/docs/package.json
index 921ee39..5dce993 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -8,6 +8,7 @@
},
"devDependencies": {
- "vitepress": "^1.0.0-rc.4"
+ "vitepress": "1.0.0-rc.25",
+ "vitepress-plugin-mermaid": "2.0.14"
}
}
diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg
new file mode 100644
index 0000000..85488e0
--- /dev/null
+++ b/docs/public/full_logo.svg
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/public/logo.svg b/docs/public/logo.svg
index 0e64a32..92b06c1 100644
--- a/docs/public/logo.svg
+++ b/docs/public/logo.svg
@@ -1,4 +1,39 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md
new file mode 100644
index 0000000..17c95d2
--- /dev/null
+++ b/docs/tut/advanced/nested-scoping.md
@@ -0,0 +1,188 @@
+# Nested Reactive Scopes
+
+Nesting reactive scopes gives you finer control over the reactive graph, but
+needs more work to do. The built-in control flow functions try to cover the
+most common cases, but they do not cover all of them.
+
+This tutorial will demonstrate how to implement a `show()` control flow function
+using just sources and effects.
+
+```lua
+local mount = vide.mount
+local source = vide.source
+local show = vide.show
+
+local function Counter()
+ local count = source(0)
+
+ return create "TextButton" {
+ Text = count,
+ Activated = function() count(count() + 1) end
+ }
+end
+
+mount(function()
+ local toggled = source(true)
+
+ show(toggled, Button)
+end)
+```
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#1C1C1F"
+ }
+}}%%
+
+graph
+
+subgraph mount
+ direction LR
+ toggle --> show
+
+ subgraph show[show effect]
+ text[Text effect]
+ end
+end
+```
+
+Above is the reactive graph for `show()`. It creates a new effect depending on
+`toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The
+`show` effect calls `Counter`, which creates a new reactive scope to update its
+text whenever `count` changes. As per the rules of reactive scopes, a reactive
+scope rerunning will destroy any reactive scope created within it. So the text
+effect's reactive scope is destroyed whenever the show effect is rerun.
+
+The same can be achieved without the use of `show()`:
+
+```lua
+local mount = vide.mount
+local source = vide.source
+local effect = vide.effect
+local cleanup = vide.cleanup
+
+local function Counter()
+ local count = source(0)
+
+ return create "TextButton" {
+ Text = count,
+ Activated = function() count(count() + 1) end
+ }
+end
+
+mount(function()
+ local toggled = source(true)
+
+ effect(function()
+ if toggled() then
+ local destroy = mount(Button)
+ cleanup(destroy)
+ end
+ end)
+end)
+```
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#1C1C1F"
+ }
+}}%%
+
+graph
+
+subgraph mount
+ direction LR
+ toggle --> effect
+
+ subgraph effect
+ subgraph mount2[inner mount]
+ text[Text effect]
+ end
+ end
+end
+```
+
+This is another way to achieve the same. Here we use `mount()` within the effect
+to manually create and destroy a new reactive scope whenever the effect reruns.
+
+Alternatively, instead of using `mount()`, a new reactive scope can be created
+directly within the effect:
+
+```lua
+local mount = vide.mount
+local source = vide.source
+local effect = vide.effect
+local untrack = vide.untrack
+
+local function Counter()
+ local count = source(0)
+
+ return create "TextButton" {
+ Text = count,
+ Activated = function() count(count() + 1) end
+ }
+end
+
+mount(function()
+ local toggled = source(true)
+
+ effect(function()
+ if toggled() then
+ untrack(Button)
+ end
+ end)
+end)
+```
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#1C1C1F"
+ }
+}}%%
+
+graph
+
+subgraph mount
+ direction LR
+ toggle --> effect
+
+ subgraph effect
+ text[Text effect]
+ end
+end
+```
+
+Without the use of `untrack()`, an error would occur, since Vide does not allow
+the creation of reactive scopes inside reactive scopes that are tracking. The
+reason for this, is because if the `Counter` component reads from a source
+internally, that can cause the reactive scope calling `Counter()` to track that
+source, causing unintentional reruns. As a guard against this, you are forced to
+use `untrack()` to create nested reactive scopes.
+
+The final result is the same as using the `show()` component. An effect is
+created which creates the counter, which creates its own reactive scope. The
+effect rerunning causes the counter's internal reactive scope to be destroyed,
+making sure everything is cleaned up.
+
+
diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md
new file mode 100644
index 0000000..8628e45
--- /dev/null
+++ b/docs/tut/control-flow/2-show.md
@@ -0,0 +1 @@
+# show()
diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md
new file mode 100644
index 0000000..647835a
--- /dev/null
+++ b/docs/tut/control-flow/3-switch.md
@@ -0,0 +1 @@
+# switch()
diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md
new file mode 100644
index 0000000..aad2c90
--- /dev/null
+++ b/docs/tut/control-flow/4-indexes.md
@@ -0,0 +1 @@
+# indexes()
diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md
new file mode 100644
index 0000000..8909904
--- /dev/null
+++ b/docs/tut/control-flow/5-values.md
@@ -0,0 +1 @@
+# values()
diff --git a/docs/tut/control-flow/indexes.md b/docs/tut/control-flow/indexes.md
deleted file mode 100644
index 70864e7..0000000
--- a/docs/tut/control-flow/indexes.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Control Flow
-
-Vide has specific functions for dealing with sources that store a table value.
-
-Often, you will have a table of values that will be displayed in a similar
-manner. Rather than manually looping over each value to generate a corresponding
-UI element, Vide provides functions `indexes()` and `values()` to do this for
-you.
-
-`indexes()` maps each *index* in a table to a UI element.
-
-```lua
-local names = source { "a", "b", "c" }
-
-local elements = indexes(names, function(name, i)
- return create "TextLabel" {
- Text = function()
- return "Name: " .. name()
- end,
-
- LayoutOrder = i
- }
-end)
-```
-
-What happens here is the given callback is only ever ran *once* for each index
-in the table. The callback receives two arguments, a *source* containing the
-index's value and then the index itself.
-
-Anytime the value at a corresponding index changes, the source for that index
-value is updated, causing the UI element depending on it to update too.
-
-`values()` behaves similarly, except it maps each *value* in a table to a UI
-element.
-
-```lua
-type Item = {
- Name: string,
- Icon: number
-}
-
-local items = source({} :: Array- )
-
-local elements = values(items, function(item, i)
- return create "ImageLabel" {
- Image = "rbxassetid://" .. item.Icon,
- LayoutOrder = i
- }
-end)
-```
-
-The callback is again only ever ran *once* for each value in the table. The
-callback receives two arguments, a value in the table and then a *source*
-containing the value's corresponding index.
-
-Any time a value in a table changes index, the source for that value is updated,
-causing the UI element position to change.
-
-In certain cases `values()` can cause less recalculation and rerenders than
-`indexes()` like when items are re-arranged and shifted within a table.
-
-It is important that each value in a table is unique when using `values()`,
-and for this reason always using `indexes()` if a table contains primitive
-values.
-
-Both `indexes()` and `values()` return an array of all mapped UI elements.
diff --git a/docs/tut/control-flow/switch.md b/docs/tut/control-flow/switch.md
deleted file mode 100644
index 70864e7..0000000
--- a/docs/tut/control-flow/switch.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# 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
deleted file mode 100644
index 70864e7..0000000
--- a/docs/tut/control-flow/values.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# 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 7b00dac..261ce83 100644
--- a/docs/tut/crash-course/1-introduction.md
+++ b/docs/tut/crash-course/1-introduction.md
@@ -1,47 +1,25 @@
# Introduction
-This is a brief tutorial designed to give you a quick run through the usage of
-Vide.
+This is a tutorial that introduces the concepts and usage of Vide.
Vide is heavily inspired by [Solid](https://www.solidjs.com/).
+This tutorial assumes familiarity with Luau and Roblox UI.
+
## Why Vide?
-Creating UI is a slow and tedious process. The purpose of Vide is to make UI
-declarative and concise, making it faster to create and more importantly easier
-to maintain. Vide achieves this using a reactive style of programming which
-allows you to focus on the flow of data through your application without
-worrying about manually updating UI instances.
+Creating UI is complicated, slow, and tedious.
+
+Vide tries to simplify and speed up this process by providing a declarative and
+reactive of style programming, which lets you focus more on designing the UI
+itself and not having to manually update or reparent UI instances.
Some of the main focuses behind Vide's design choices:
-- Concise syntax to reduce verbosity as much as possible.
-- Reducing the amount of imports needed for usage by leveraging Luau's syntax
- and semantics.
-- Being completely typecheckable.
-- 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.
+- Minimal syntax.
+- Complete typechecking
+- Independence from instances.
-## 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)
-```
+As with most declarative libraries, there is an initial learning curve to
+understand the concepts and usage. This tutorial tries to comprehensively
+cover these concepts and usage, more so than you need just to use it.
diff --git a/docs/tut/crash-course/10-actions.md b/docs/tut/crash-course/10-actions.md
deleted file mode 100644
index 68fb06e..0000000
--- a/docs/tut/crash-course/10-actions.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Actions
-
-Actions in Vide are special callbacks that you can pass along with properties,
-which will be called when those properties are being processed with the instance
-being assigned to, allowing you to run custom code.
-
-```lua
-local action = vide.action
-```
-
-```lua
-create "TextLabel" {
- Text = "test",
-
- action(function(instance)
- print(instance.Text)
- end)
-}
-
--- will print "test"
-```
-
-Actions can be wrapped with functions to re-use specific behaviors. Below is
-an example of an action used to listen for property changes:
-
-```lua
-local function changed(property: string, callback: (new) -> ())
- return action(function(instance)
- instance:GetPropertyChangedSignal(property):Connect(function()
- callback(instance[property])
- end)
- end)
-end
-
-local output = source ""
-
-create "TextBox" {
- changed("Text", output)
-}
-```
-
-The source `output` will be updated with the new property value any time it is
-changed externally.
diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md
new file mode 100644
index 0000000..bf632f5
--- /dev/null
+++ b/docs/tut/crash-course/10-cleanup.md
@@ -0,0 +1,51 @@
+# 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 queue a cleanup callback for the next time a reactive scope is rerun
+or destroyed.
+
+```lua
+local mount = vide.mount
+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: " .. math.floor(count())
+ end,
+ }
+end
+
+local unmount = mount(Timer)
+
+unmount() -- all queued cleanups are ran, heartbeat connection disconnected
+```
+
+In the above example, this allows us to disconnect the heartbeat connection
+when the reactive scope responsible for creating the timer component is
+destroyed, such as when it is unmounted.
+
+::: tip
+Roblox instances do not need to be explicitly destroyed for their
+memory to be freed, they only need to be parented to `nil`. So there is no
+need to use `cleanup()` to destroy instances. However, be wary of connecting
+a function that references an instance to an event from the same instance,
+this causes the instance to reference itself and never be freed. In such a case
+you would need to use `cleanup()` to disconnect this connection or to explicitly
+destroy the instance.
+:::
diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md
new file mode 100644
index 0000000..91f95ac
--- /dev/null
+++ b/docs/tut/crash-course/11-control-flow.md
@@ -0,0 +1,202 @@
+# Control Flow
+
+Eventually you may need a way to dynamically create and destroy UI elements
+resulting from source updates. Vide provides functions to help you do this,
+known as *control flow* functions.
+
+These functions return new sources, which hold the instances to be displayed.
+
+Control flow functions run their components in a new reactive scope, which can
+be destroyed independently of the reactive scope that called the control flow
+function. This means parts of your app can be independently created and
+destroyed.
+
+## switch()
+
+`switch()` condtionally displays one instance at a time. It uses a table to map
+a source value to a component.
+
+```lua
+local source = vide.source
+local switch = vide.switch
+
+local function Button(props: {
+ Text: string,
+ Activated: () -> ()
+})
+ local hovered = source(false)
+
+ return create "TextButton" {
+ Text = props.Text,
+ Activated = props.Activated,
+
+ TextColor3 = function()
+ return hovered() and Color3.new(1, 1, 1) or Color3.new(.7, .7, .7)
+ end,
+
+ MouseEnter = function() hovered(true) end,
+ MouseLeave = function() hovered(false) end
+ }
+end
+
+local function JoinMenu()
+ local joined = source(false)
+
+ local function JoinButton()
+ return Button {
+ Text = "Join",
+ Activated = function() joined(true) end
+ }
+ end
+
+ local function LeaveButton()
+ return Button {
+ Text = "Leave"
+ Activated = function() joined(false) end
+ }
+ end
+
+ return create "Frame" {
+ switch(joined) {
+ [true] = LeaveButton,
+ [false] = JoinButton
+ }
+ }
+end
+```
+
+The reactive graph for the above example:
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#1C1C1F"
+ }
+}}%%
+
+graph
+
+subgraph root["root scope"]
+ direction LR
+ joined --> switch -.- subroot
+
+ subgraph subroot["switch scope"]
+ direction LR
+ effect["TextColor3 effect"]
+ end
+end
+```
+
+A `switch()` call creates a new effect and a new scope as seen in the above
+graph. Whenever `menu` updates, it causes the `switch` effect to run, which
+will destroy and recreate the switch scope with the new component.
+
+This will also destroy the internal effect that the button uses to highlight
+itself when it is hovered, each time the switch is rerun.
+
+## indexes()
+
+Often, you will have a table of values with each value displayed in a similar
+manner. Rather than manually looping over each value to generate a corresponding
+UI element, `indexes()` allows you to create elements each corresponding to a
+table index, to display the value at that index.
+
+```lua
+local todoList = source {
+ "finish the crash course",
+ "star vide's GitHub"
+}
+
+local function TodoList(props: { list: () -> Array
})
+ return create "Frame" {
+ create "UIListLayout" {},
+
+ indexes(todoList, function(todo, i)
+ return create "TextLabel" {
+ Text = function()
+ return i .. ": " .. todo()
+ end,
+
+ LayoutOrder = i
+ }
+ end)
+ }
+end
+
+TodoList { list = todoList }
+```
+
+For each index in the given source table, the given 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 for that index is updated.
+
+Any time the input source table is updated, the given function will be ran for
+any newly added indexes, while any removed indexes (indexes now with a `nil`
+value), will have its corresponding reactive scope destroyed to clean up that
+element.
+
+`indexes()` is said to *map* each table index to a new UI element that can
+update to display the current value at that index. Each table index is given a
+single corresponding UI element.
+
+The reactive graph for the above example:
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#1C1C1F"
+ }
+}}%%
+
+graph
+
+subgraph root ["root scope"]
+ direction LR
+ todoList --> indexes -.- subroot1 & subroot2
+
+ subgraph subroot1 ["indexes scope 1"]
+ direction LR
+ value1[todo] --> prop1["prop binding"]
+ end
+
+ subgraph subroot2 ["indexes scope 2"]
+ direction LR
+ value2[todo] --> prop2[prop binding]
+ end
+end
+```
+
+One thing to note regarding table sources, is that when you edit a table in a
+source, you must set that table again to actually update the source.
+
+```lua
+local src = source { 1, 2 }
+local data = src()
+table.insert(data, 3) -- no effects will run
+src(data) -- effects will run
+```
+
+Together, these control flow functions cover the majority of cases where you
+need to dynamically create and destroy parts of your UI.
+
+If you need to do something that these control flow functions cannot, you can
+always use `mount()` within an effect to dynamically create and destroy
+components on your own terms. Just remember to use `cleanup()` to unmount when
+the effect reruns.
diff --git a/docs/tut/crash-course/9-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md
similarity index 94%
rename from docs/tut/crash-course/9-property-nesting.md
rename to docs/tut/crash-course/12-property-nesting.md
index 1a61425..d8c44ce 100644
--- a/docs/tut/crash-course/9-property-nesting.md
+++ b/docs/tut/crash-course/12-property-nesting.md
@@ -27,7 +27,7 @@ function Menu(props: {
Size: UDim2
})
return Background {
- Color = props.COlor,
+ Color = props.Color,
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
@@ -77,7 +77,8 @@ be parented.
```lua
type Children = {
- Children = Array
+ -- also can optionally pass a source that returns an array of children too
+ Children = Array | () -> Array
}
local function List(props: Children & Layout)
@@ -107,8 +108,8 @@ properties, this can be used to create overridable default properties.
local function List(props: Children & Layout)
return create "Frame" {
props.Children,
-
props.Layout,
+
-- can be overriden by `props.Layout`
AnchorPoint = Vector2.new(0.5, 0),
Position = UDim2.fromScale(0.5, 0),
diff --git a/docs/tut/crash-course/13-actions.md b/docs/tut/crash-course/13-actions.md
new file mode 100644
index 0000000..06b431d
--- /dev/null
+++ b/docs/tut/crash-course/13-actions.md
@@ -0,0 +1,53 @@
+# Actions
+
+Actions in Vide are special callbacks that you can pass along with properties,
+to run some code on an instance receiving them.
+
+```lua
+local action = vide.action
+```
+
+```lua
+create "TextLabel" {
+ Text = "test",
+
+ action(function(instance)
+ print(instance.Text)
+ end)
+}
+
+-- will print "test"
+```
+
+Actions can be wrapped with functions for reuse. Below is an example of an
+action used to listen for property changes:
+
+```lua
+local action = vide.action
+local cleanup = vide.cleanup
+
+local function changed(prop: string, callback: (new) -> ())
+ return action(function(instance)
+ local connection = instance:GetPropertyChangedSignal(prop):Connect(function()
+ callback(instance[property])
+ end)
+
+ -- remember to clean up the connection when the reactive scope the action
+ -- is ran in is destroyed, so the instance can be garbage collected
+ cleanup(connection)
+ end)
+end
+
+local output = source ""
+
+local instance = create "TextBox" {
+ changed("Text", output)
+}
+
+instance.Text = "foo"
+
+print(output()) -- "foo"
+```
+
+The source `output` will be updated with the new property value any time it is
+changed externally.
diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/14-strict-mode.md
new file mode 100644
index 0000000..ca8465a
--- /dev/null
+++ b/docs/tut/crash-course/14-strict-mode.md
@@ -0,0 +1,35 @@
+# Strict Mode
+
+While developing UI with Vide, you should use Vide's strict mode, which can
+be set with `vide.strict = true` once when you first require Vide. Strict mode
+will add extra safety checks and emit better error traces, particularly when
+errors occur in property bindings.
+
+Strict mode is automatically enabled when Vide is required in O0 or O1
+optimization (default studio level). You can `vide.strict = false` if you do not
+want this.
+
+Strict mode will run derived sources and effects twice each time they update.
+This is to help ensure that derived source computations are pure, and that any
+cleanups made in derived sources or effects are done correctly.
+
+```lua
+local source = vide.source
+local effect = vide.effect
+
+vide.strict = true
+
+local count = source(0)
+
+local ran = 0
+effect(function()
+ ran += 1
+end)
+
+print(ran) -- 2
+count(1)
+print(ran) -- 4
+```
+
+A full list of what strict mode will do can be found
+[here](../../api/strict-mode).
diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md
new file mode 100644
index 0000000..62ee35d
--- /dev/null
+++ b/docs/tut/crash-course/15-concepts.md
@@ -0,0 +1,123 @@
+# Concepts Summary
+
+A summary of all the concepts covered during the crash course.
+
+## Source
+
+A source of data.
+
+Stores a single value that can be updated.
+
+Created with `source()`.
+
+# Derived Source
+
+A new source composed of other sources.
+
+Created with a plain function or with `derive()`.
+
+## Effect
+
+Anything that happens in response to a source update.
+
+Created with `effect()`.
+
+## Reactive Scope
+
+A scope created by certain functions such as:
+
+- `root()`
+- `effect()`
+- `derive()`
+
+Reactive scopes can:
+
+- track sources that are read from within.
+- rerun when a tracked source updates.
+- track new reactive scopes created from within.
+
+## Scope Owners
+
+A reactive scope created within another reactive scope is *owned* by the other
+reactive scope, with the exception of the reactive scope created by `root()`.
+
+When a reactive scope is rerun or destroyed, all reactive scopes owned by it are
+automatically destroyed.
+
+`root()`, which `mount()` uses internally, creates a reactive scope with no
+owner, since it must be destroyed manually using a destructor
+returned.
+
+## Cleanup
+
+Arbitrary code to run whenever a reactive scope is rerun or destroyed.
+
+Queue a function to run using `cleanup()`.
+
+## Tracking
+
+Sources read from within a reactive scope will be tracked. This can be disabled
+using `untrack()`, which will make reactive scopes temporarily ignore sources
+read.
+
+The reactive scope created by `root()` is non-tracking by default.
+
+As a guard against misusage, a reactive scope cannot be created within a
+reactive scope, unless it is made non-tracking using `untrack()`.
+
+## Reactive Graph
+
+The combination of reactive scopes can viewed graphically, called a
+*reactive graph*. This can be a more intuitive way to think of the
+relationships between effects and the sources they depend on.
+
+### Code
+
+```lua
+local count = source(0)
+
+root(function()
+ local text = derive(function()
+ return "count: " .. text()
+ end)
+
+ effect(function()
+ print(text())
+ end)
+end)
+```
+
+### Graph resulting from code
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#1C1C1F"
+ }
+}}%%
+
+graph LR
+
+subgraph root
+ text --> effect
+end
+
+count --> text
+```
+
+Notes:
+
+- Since `count` is a source, not an effect, it can exist
+ outside of a root reactive scope.
+- An update to `count` will cause `text` to rerun, which
+ then causes `effect` to rerun.
+- When the root reactive scope is destroyed, `text` and
+ `effect` will be destroyed alongside it, since they are
+ owned by it. `count` will be untouched and future updates
+ to `count` will have no effect.
diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md
index deb1683..96f9dd2 100644
--- a/docs/tut/crash-course/2-creation.md
+++ b/docs/tut/crash-course/2-creation.md
@@ -6,53 +6,43 @@ Instances are created using `create()`.
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 which Vide takes advantage of for brevity.
+table literals which is recommended to use for brevity.
```lua
-local vide = require(vide)
-local mount = vide.mount
local create = vide.create
-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),
+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),
- create "TextLabel" {
- Text = "hi"
- },
+ create "TextLabel" {
+ Text = "hi"
+ },
- create "TextLabel" {
- Text = "bye"
- },
+ create "TextLabel" {
+ Text = "bye"
+ },
- create "TextButton" {
- Text = "click me",
+ create "TextButton" {
+ Text = "click me",
- Activated = function()
- print "clicked!"
- end
- }
+ Activated = function()
+ print "clicked!"
+ end
}
}
-end
-
-mount(App, game.StarterGui)
+}
```
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.
-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" {
- AnchorPoint = { 0.5, 1 },
- UDim2 = { 0.5, 0, 0.5, 0 }
-}
-```
+::: warning
+When creating an instance with no properties, it is important to not forget to
+actually call the constructor: `create "Frame" {}` and not `create "Frame"`.
+To be clear, `create "Frame"` returns a *function* which is a constructor for
+that class, not an instance of that class.
+:::
diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md
index 712ab9a..aa81916 100644
--- a/docs/tut/crash-course/3-components.md
+++ b/docs/tut/crash-course/3-components.md
@@ -1,12 +1,16 @@
# Components
-Components are custom-made reusable pieces of UI made from other pieces of UI.
+Vide encourages separating different parts of your UI into functions called
+*components*.
-By using components you can make your application more modular and better
-organized.
+A component is a function that creates and returns a piece of UI.
+
+This is a way to separate your UI into small chunks that you can reuse and put
+together.
+
+::: code-group
```lua [Button.luau]
-local vide = require(vide)
local create = vide.create
local function Button(props: {
@@ -16,11 +20,14 @@ local function Button(props: {
})
return create "TextButton" {
BackgroundColor3 = Color3.fromRGB(50, 50, 50),
+ TextColor3 = Color3.fromRGB(255, 255, 255),
Size = UDim2.fromOffset(200, 150),
Position = props.Position,
Text = props.Text,
- Activated = props.Activated
+ Activated = props.Activated,
+
+ create "UICorner" {}
}
end
@@ -28,8 +35,6 @@ return Button
```
```lua [App.luau]
-local vide = require(vide)
-local mount = vide.mount
local create = vide.create
local Button = require(Button)
@@ -38,29 +43,35 @@ local function App()
return create "ScreenGui" {
Button {
Position = UDim2.fromOffset(200, 200),
- Text = "click me!",
-
+ Text = "back",
Activated = function()
- print "clicked"
+ print "go to previous page"
+ end
+ },
+
+ Button {
+ Position = UDim2.fromOffset(400, 200),
+ Text = "next",
+ Activated = function()
+ print "go to next page"
end
}
}
end
-mount(App, game.StarterGui)
+App().Parent = game.StarterGui
```
-Above is a simple example of a button component with a set color and size,
-being reused across files.
+:::
+
+Above is a simple example of a button component being used across files.
A single parameter `props` is used to pass properties to the component.
-Components allow you to *encapsulate* behavior. You can only modify the
-component in ways that you allow in the component.
+You can only modify the component in ways that you allow in the component,
+through the `props` parameter.
-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.
+To create a new button all you must do is call the `Button` function, passing in
+values. This saves having to create and set every property each time. Also, when
+updating the button component in future, any changes to the button file will be
+seen anywhere the button is used in your app.
diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md
index cf7ceb2..c356b6b 100644
--- a/docs/tut/crash-course/4-source.md
+++ b/docs/tut/crash-course/4-source.md
@@ -1,31 +1,14 @@
-# Source
+# Sources
-*Sources* in Vide are special objects that store a single value. They are the
-core of reactivity in Vide, as updates to a source can automatically update
-properties or other sources depending on that source.
+Sources are special objects that store a single value. They are the core of
+Vide's reactivity. They are called sources because they act as sources of data.
-A source in Vide can be created using `source()`.
+A source can be created using `source()`.
```lua
-local vide = require(vide)
local source = vide.source
-local function Counter()
- local count = source(0)
-
- 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)
+local count = source(0)
```
The value passed to `source()` is the initial value of the source.
@@ -37,15 +20,22 @@ by calling it with no arguments.
count(count() + 1) -- increment count by 1
```
-Each call of `Counter {}` will create a new counter, each maintaining their
-own count.
+Sources can be *derived* by wrapping them in functions. A wrapped source
+effectively becomes a new source.
-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.
+```lua
+local count = source(0)
-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.
+local text = function()
+ return "count: " .. tostring(count())
+end
+
+print(text()) -- "count: 0"
+count(1)
+print(text()) -- "count: 1"
+```
+
+Sources on their own aren't very special, the above can be achieved with plain
+variables. The real use for sources become apparent when used in combination
+with *effects*. Similar to a signal and connection, a source and effect allows
+you to do things like automatically updating UI when a source is updated.
diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md
index 67c1f58..275a2e6 100644
--- a/docs/tut/crash-course/5-effect.md
+++ b/docs/tut/crash-course/5-effect.md
@@ -1,45 +1,51 @@
-# Effect
+# Effects
-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 functions that are ran in response to source updates. They are
+called effects because they cause *side-effects* when reacting to source
+updates.
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)
+local count = source(0)
- effect(function()
- print("count has updated to: " .. count())
- end)
+effect(function()
+ print("count: " .. 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)
+-- "count: 0" printed
+count(1)
+-- "count: 1" printed
```
-This will print to the terminal anytime the count is changed.
+The callback given to `effect()` is ran immediately in a *reactive scope*. Any
+source read from inside a reactive scope will be tracked, so when any of those
+sources update, the effect will be reran too.
-`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.
+Reactive scopes also track derived sources, it doesn't matter how deeply nested
+inside a function a source is.
-All observable changes to the user are considered to be side-effects of the
-reactive system.
+```lua
+local source = vide.source
+local effect = vide.effect
-You should not update other sources using an effect. Improper usage can lead to
-unecessary updates and infinite loops.
+local count = source(1)
+
+local doubled = function()
+ return count() * 2
+end
+
+effect(function()
+ print("doubled count: " .. doubled())
+end)
+
+-- "doubled count: 2" printed
+count(2)
+-- "doubled count: 4" printed
+```
+
+If a source is updated with the same value it already had, it will not rerun
+effects depending on it.
diff --git a/docs/tut/crash-course/6-derived-source.md b/docs/tut/crash-course/6-derived-source.md
deleted file mode 100644
index 01fe1c2..0000000
--- a/docs/tut/crash-course/6-derived-source.md
+++ /dev/null
@@ -1,76 +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 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/6-root.md b/docs/tut/crash-course/6-root.md
new file mode 100644
index 0000000..4cb47da
--- /dev/null
+++ b/docs/tut/crash-course/6-root.md
@@ -0,0 +1,80 @@
+# Root Reactive Scopes
+
+Reactive scopes cannot be created on their own - they must be created within
+another reactive scope so that it can be tracked and later destroyed when it is
+no longer needed.
+
+This is the purpose of `mount()`, which creates an initial "root", or
+"top-level" reactive scope, which all other reactive scopes, such as
+ones created by `effect()`, can stem from.
+
+When this root reactive scope is destroyed, it will ensure all other reactive
+scopes created within it are also destroyed, ensuring everything is cleaned up
+properly.
+
+```lua
+local source = vide.source
+local effect = vide.effect
+
+local function App()
+ local count = source(0)
+
+ effect(function()
+ print(count())
+ end)
+end
+
+
+App() -- will error since effect() was not called within a reactive scope
+
+vide.mount(App) -- works!
+
+```
+
+Mounting returns a function that when called will destroy its reactive scope,
+along with any other reactive scopes created inside it.
+
+```lua
+local unmount = mount(App)
+
+unmount()
+```
+
+Vide's reactivity can be represented graphically, as a *reactive graph*.
+
+The reactive graph for the above example looks like so:
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#161618"
+ }
+}}%%
+
+graph
+
+subgraph root
+ direction LR
+ count --> effect
+end
+```
+
+When the root reactive scope created by `mount()` is destroyed, the `effect`
+scope will also be destroyed since it was created within it.
+
+This is important because you may have an effect that updates the property of a
+UI instance, meaning the effect is referencing and holding that instance in
+memory. The effect being destroyed will remove this reference, allowing the
+instance to be garbage collected.
+
+You don't need to worry about ensuring all your effects are created within a
+root reactive scope, since you should be creating all your UI and corresponding
+effects within a top-level `mount()` call that puts all your UI together. So it
+is safe to assume that any effect you create will be created under this top
+level scope. Vide will prevent you from accidently doing otherwise anyways.
diff --git a/docs/tut/crash-course/7-cleanup.md b/docs/tut/crash-course/7-cleanup.md
deleted file mode 100644
index 3e5525d..0000000
--- a/docs/tut/crash-course/7-cleanup.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# 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/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md
new file mode 100644
index 0000000..85139db
--- /dev/null
+++ b/docs/tut/crash-course/7-stateful-component.md
@@ -0,0 +1,75 @@
+# Stateful Components
+
+A stateful component is a component that stores some data internally.
+
+Stateful components in Vide are created using sources and effects - sources to
+store the data, and effects to display the data.
+
+## Internal State
+
+```lua
+local create = vide.create
+local source = vide.source
+local effect = vide.effect
+
+local function Counter()
+ local count = source(0)
+
+ local instance = create "TextButton" {
+ Activated = function()
+ count(count() + 1)
+ end
+ }
+
+ effect(function()
+ instance.Text = "count: " .. count()
+ end)
+
+ return instance
+end
+
+mount(Counter, game.StarterGui)
+```
+
+Above is an example of a counter component, that when clicked, will increment
+its internal count, and automatically update its text to reflect that count.
+
+Each instance of `Counter()` will maintain its own independent count, since the
+count source is created inside the component.
+
+We use `mount()` to create the counter within a reactive scope, which also takes
+a second argument to parent the counter to another instance.
+
+## External State
+
+External sources can also be passed into components for them to use.
+
+```lua
+local function Counter(props: { count: () -> number })
+ local count = props.count
+
+ local instance = create "TextButton" {
+ Activated = function()
+ count(count() + 1)
+ end
+ }
+
+ effect(function()
+ instance.Text = "count: " .. count()
+ end)
+
+ return instance
+end
+
+local count = source(0)
+
+Counter {
+ count = count
+}
+
+count(1) -- the Counter component will update to display this count
+```
+
+Sources can be created internally or passed in from externally, there are no
+restrictions on how they are used as long as the effect using it is created
+within a reactive scope.
diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/8-control-flow.md
deleted file mode 100644
index f590145..0000000
--- a/docs/tut/crash-course/8-control-flow.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# 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/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md
new file mode 100644
index 0000000..1fe18fc
--- /dev/null
+++ b/docs/tut/crash-course/8-property-binding.md
@@ -0,0 +1,66 @@
+# Property Binding
+
+Explicitly creating effects to update properties can be tedious. Vide provides a
+way to *implicitly* create an effect to update properties.
+
+```lua
+local create = vide.create
+local source = vide.source
+
+local function Counter()
+ local count = source(0)
+
+ return create "TextButton" {
+ Activated = function()
+ count(count() + 1)
+ end,
+
+ Text = function()
+ return "count: " .. count()
+ end
+ }
+end
+```
+
+This example is equivalent to the example seen on the previous page.
+
+Instead of explicitly creating an effect, assigning a (non-event) property a
+function will implicitly create an effect to update that property anytime a
+source used within is updated.
+
+Just like effects, the function is ran immediately in a reactive scope to set
+the property initially and determine what sources are being used.
+
+This allows you as the programmer to not need to manually update UI as the state
+of your program changes. You just define how data sources map to UI, and Vide's
+reactive system will automatically update any properties depending on those
+sources.
+
+## Children Binding
+
+Children can also be set in a similar manner. A source passed as a child (passed
+with a number key instead of string key) can return an instance or an array of
+instances. Vide will automatically unparent removed instances and parent new
+instances when that source's stored instances change.
+
+```lua
+local items = source {
+ create "TextLabel" { Text = "A" }
+}
+
+local function List(props: { children: () -> { Instance } })
+ return create "Frame" {
+ create "UIListLayout" {},
+ props.children
+ }
+end
+
+local list = List { children = items } -- creates a list with a single text label "A"
+
+items {
+ create "TextLabel" { Text = "B" },
+ create "TextLabel" { Text = "C" }
+}
+
+-- this will automatically unparent the text label "A", and parent the labels "B" and "C".
+```
diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md
new file mode 100644
index 0000000..d3b7156
--- /dev/null
+++ b/docs/tut/crash-course/9-derived-source.md
@@ -0,0 +1,92 @@
+# Derived Sources
+
+We have seen the basic way to derive a source:
+
+```lua
+local count = source(0)
+
+local text = function()
+ return "count: " .. tostring(count())
+end
+
+print(text()) -- "count: 0"
+count(1)
+print(text()) -- "count: 1"
+```
+
+However, in some cases where this source could be used by multiple effects at
+the same time, the function wrapping the source will needlessly rerun to convert
+the count into a string for each effect using it.
+
+```lua
+local source = vide.source
+local effect = vide.effect
+
+local count = source(0)
+
+local text = function()
+ print "ran"
+ return "count: " .. tostring(count())
+end
+
+effect(function() text() end)
+effect(function() text() end)
+
+source(1) -- prints "ran" x2
+```
+
+To avoid this, you can use `derive()` to derive a new source instead. This will
+run a callback in a new reactive scope only when a dependent source has updated.
+Reading this derived source multiple times will just return a cached result from
+when it last updated.
+
+```lua
+local source = vide.source
+local derive = vide.derive
+local effect = vide.effect
+
+local count = source(0)
+
+local text = derive(function()
+ print "ran"
+ return "count: " .. tostring(count())
+end)
+
+effect(function() text() end)
+effect(function() text() end)
+
+source(1) -- prints "ran" x1
+```
+
+`derive()` must also be called within a reactive scope, just like `effect()`.
+
+If the recalculated value is the same as the old value, the derived source will
+not rerun the effects using it.
+
+The reactive graph for the above example:
+
+```mermaid
+%%{init: {
+ "theme": "base",
+ "themeVariables": {
+ "primaryColor": "#1B1B1F",
+ "primaryTextColor": "#fff",
+ "primaryBorderColor": "#1B1B1F",
+ "lineColor": "#79B8FF",
+ "tertiaryColor": "#161618",
+ "tertiaryBorderColor": "#161618"
+ }
+}}%%
+
+graph
+
+subgraph root
+ direction LR
+ count --> text --> effect1 & effect2
+end
+```
+
+Deriving a source in this manner is similar to creating an effect to update
+another source. You should never manually do this using an effect however,
+improper usage could accidently create infinite loops in the reactive graph.
+Always favour deriving when you need one source to update based on another.
diff --git a/docs/tut/reactive-scoping.md b/docs/tut/reactive-scoping.md
deleted file mode 100644
index 5b983ab..0000000
--- a/docs/tut/reactive-scoping.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 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 f40bf3a..8cc4987 100644
--- a/src/action.luau
+++ b/src/action.luau
@@ -10,14 +10,14 @@ local function is_action(v: any)
end
local function action(callback: (Instance) -> (), priority: number?): Action
- local t = {
+ local a = {
priority = priority or 1,
callback = callback
}
- setmetatable(t :: any, ActionMT)
+ setmetatable(a :: any, ActionMT)
- return table.freeze(t)
+ return table.freeze(a)
end
return function()
diff --git a/src/apply.luau b/src/apply.luau
index d0bccf7..13e8cab 100644
--- a/src/apply.luau
+++ b/src/apply.luau
@@ -13,41 +13,58 @@ type Node = graph.Node
type Array = { V }
type Map = { [K]: V }
--- buffer of event -> callback to connect after properties are set
-local event_buffer = {} :: Map ()>
+local free_caches: {
+ -- event listeners to connect after properties are set
+ events: Map<
+ string, -- event name
+ () -> () -- listener
+ >,
--- buffer of priority -> callback to run after events are connected
-local action_buffers = {} :: Map ()>>
+ -- actions to run after events are connected
+ actions: Map<
+ number, -- priority
+ Array<(Instance) -> ()> -- action callbacks
+ >,
--- lazily create buffers on nil index
-setmetatable(action_buffers :: any, {
- __index = function(_, i: number)
- action_buffers[i] = {}
- return action_buffers[i]
+ -- cache to detect duplicate property setting at same nesting depth
+ nested_debug: Map<
+ number, -- depth
+ Map -- set of property names
+ >,
+
+ -- use stack instead of recursive function to process nesting 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 }
+ nested_stack: { {} | number }
+}?
+
+local function borrow_caches(): typeof(assert(free_caches))
+ if free_caches then
+ local caches = free_caches :: typeof(assert(free_caches))
+ free_caches = nil
+ return caches
+ else
+ return {
+ events = {},
+ actions = setmetatable({} :: any, { -- lazy init
+ __index = function(self, i) self[i] = {}; return self[i] end
+ }),
+ nested_debug = setmetatable({} :: any, {
+ __index = function(self, i: number) self[i] = {}; return self[i] end
+ }),
+ nested_stack = {}
+ }
end
-})
+end
--- cache in strict mode to detect duplicate property set at same nesting level
-local nested_debug_cache = {} :: Map>
+local function return_caches(caches: typeof(free_caches) )
+ free_caches = caches
+end
-setmetatable(nested_debug_cache :: any, {
- __index = function(_, i: number)
- nested_debug_cache[i] = {}
- return nested_debug_cache[i]
- end
-})
-
--- 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 init
local aggregates = {}
-
-for i, v in next, {
+for name, class in {
CFrame = CFrame,
Color3 = Color3,
UDim = UDim,
@@ -55,27 +72,39 @@ for i, v in next, {
Vector2 = Vector2,
Vector3 = Vector3,
Rect = Rect
-} do
- aggregates[i] = v.new
+} :: Map do
+ aggregates[name] = class.new
end
--- processes a potentially nested table of values to assign to an instance
-local function process_props(instance: Instance, properties: Map)
+-- applies table of nested properties to an instance using full vide semantics
+local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T
+ if not properties then
+ throw("attempt to call a constructor returned by create() with no properties")
+ end
+
local strict = flags.strict
- table.clear(nested_stack)
- if strict then table.clear(nested_debug_cache) end
+ -- queue parent assignment if any for last
+ local parent: unknown = properties.Parent
+ local caches = borrow_caches()
+ local events = caches.events
+ local actions = caches.actions
+ local nested_debug = caches.nested_debug
+ local nested_stack = caches.nested_stack
+
+ -- process all properties
local depth = 1
-
repeat
for property, value in properties do
+ if property == "Parent" then continue end
+
if type(property) == "string" then
- if strict then -- check for duplicate prop assignment at nesting layer
- if nested_debug_cache[depth][property] then
+ if strict then -- check for duplicate prop assignment at nesting depth
+ if nested_debug[depth][property] then
throw(`duplicate property {property} at depth {depth}`)
end
- nested_debug_cache[depth][property] = true
+ nested_debug[depth][property] = true
end
if type(value) == "table" then -- attempt aggregate init
@@ -86,7 +115,7 @@ local function process_props(instance: Instance, properties: Map () -- add event to buffer
+ events[property] = value :: () -> () -- add event to buffer
else
bind.property(instance, property, value :: () -> ()) -- bind property
end
@@ -98,7 +127,7 @@ local function process_props(instance: Instance, properties: Map 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
+ table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer
else
table.insert(nested_stack, value :: {})
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
@@ -109,36 +138,17 @@ local function process_props(instance: Instance, properties: Map(instance: T & Instance, properties: { [unknown]: unknown }): T
- -- queue parent assignment if any for last
- local parent: unknown = properties.Parent
- if parent then properties.Parent = nil end
-
- -- reset buffers
- table.clear(event_buffer)
- for _, buffer in next, action_buffers do
- table.clear(buffer)
+ for event, listener in next, events do
+ (instance :: any)[event]:Connect(listener)
end
- -- process all properties for immediate setting or buffering
- process_props(instance, properties)
-
- -- connect buffered events
- for event, fn in next, event_buffer do
- (instance :: any)[event]:Connect(fn)
- end
-
- -- run buffered actions
- for _, buffer in next, action_buffers do
- for _, callback in next, buffer do
+ for _, queued in next, actions do
+ for _, callback in next, queued do
callback(instance)
end
end
@@ -152,6 +162,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown
end
end
+ -- clear caches
+ table.clear(events)
+ for _, queued in next, actions do table.clear(queued) end
+ if strict then table.clear(nested_debug) end
+ table.clear(nested_stack)
+
+ return_caches(caches)
+
return instance
end
diff --git a/src/batch.luau b/src/batch.luau
new file mode 100644
index 0000000..1951789
--- /dev/null
+++ b/src/batch.luau
@@ -0,0 +1,25 @@
+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 function batch(setter: () -> ())
+ local already_batching = flags.batch
+
+ flags.batch = true
+
+ local ok, err: string? = pcall(setter)
+
+ if not already_batching then
+ flags.batch = false
+
+ if not already_batching then
+ graph.flush_update_queue()
+ end
+ end
+
+ if not ok then throw(`error occured while batching updates: {err}`) end
+end
+
+return batch
diff --git a/src/bind.luau b/src/bind.luau
index c614590..4a204f1 100644
--- a/src/bind.luau
+++ b/src/bind.luau
@@ -5,7 +5,7 @@ local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node = graph.Node
local create_node = graph.create_node
-local get_owning_scope = graph.get_owning_scope
+local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
@@ -31,8 +31,7 @@ function create_binding(updater: (T) -> T, binding: T)
end
end
-
- local owner = get_owning_scope()
+ local owner = assert_owning_scope()
local node = create_node(binding, updater)
diff --git a/src/changed.luau b/src/changed.luau
index 5d439c6..519a554 100644
--- a/src/changed.luau
+++ b/src/changed.luau
@@ -12,6 +12,8 @@ local function changed(property: string, callback: (T) -> ())
cleanup(function()
con:Disconnect()
end)
+
+ callback((instance :: any)[property])
end)
end
diff --git a/src/cleanup.luau b/src/cleanup.luau
index 0bfb6c1..803be03 100644
--- a/src/cleanup.luau
+++ b/src/cleanup.luau
@@ -1,19 +1,43 @@
if not game then script = require "test/relative-string" end
+local typeof = game and typeof or require "test/mock".typeof :: never
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup
-local function cleanup(callback: () -> ())
+local function helper(obj: any)
+ return
+ if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end
+ elseif typeof(obj) == "Instance" then function() obj:Destroy() end
+ elseif obj.destroy then function() obj:destroy() end
+ elseif obj.disconnect then function() obj:disconnect() end
+ elseif obj.Destroy then function() obj:Destroy() end
+ elseif obj.Disconnect then function() obj:Disconnect() end
+ else throw("cannot cleanup given object")
+end
+
+local function cleanup(value: unknown)
local scope = get_scope()
if not scope then
throw "cannot cleanup in a non-reactive scope"
end; assert(scope)
- add_cleanup(scope, callback)
+ if type(value) == "function" then
+ add_cleanup(scope, value :: () -> ())
+ else
+ add_cleanup(scope, helper(value))
+ end
end
-return cleanup
+type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () }
+type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () }
+
+return cleanup ::
+ ( (callback: () -> ()) -> () ) &
+ ( (instance: Destroyable) -> () ) &
+ ( (connection: Disconnectable) -> () ) &
+ ( (instance: Instance) -> () ) &
+ ( (connection: RBXScriptConnection) -> () )
diff --git a/src/create.luau b/src/create.luau
index 0ce889e..7adfc68 100644
--- a/src/create.luau
+++ b/src/create.luau
@@ -5,41 +5,51 @@ local Instance = game and Instance or require "test/mock".Instance :: never
local throw = require(script.Parent.throw)
local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply)
-local memoize = require(script.Parent.memoize)
+
+local ctor_cache = {} :: { [string]: () -> Instance }
+
+setmetatable(ctor_cache :: any, {
+ __index = function(self, class)
+ 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]
+ if default then
+ for i, v in next, default do
+ (instance :: any)[i] = v
+ end
+ end
+
+ local function ctor(properties: Props): Instance
+ return apply(instance:Clone(), properties)
+ end
+
+ self[class] = ctor
+ return ctor
+ 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]
- if default then
- for i, v in next, default do
- (instance :: any)[i] = v
- end
- end
-
- return function(properties: { [any]: unknown }): Instance
- return apply(instance:Clone(), properties)
- end
-end; create_instance = memoize(create_instance) -- always return same constructor for given class
+ return ctor_cache[class]
+end
local function clone_instance(instance: Instance)
- return function(properties: { [any]: unknown }): Instance
+ return function(properties: Props): Instance
local clone = instance:Clone()
- if not clone then error("Attempt to clone a non-archivable instance", 3) end
+ if not clone then throw "attempt to clone a non-archivable instance" end
return apply(clone, properties)
end
end
-local function create(class_or_instance: string|Instance)
+local function create(class_or_instance: string|Instance): (Props) -> Instance
if type(class_or_instance) == "string" then
return create_instance(class_or_instance)
elseif typeof(class_or_instance) == "Instance" then
return clone_instance(class_or_instance)
else
- throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance))
+ throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance))
+ return nil :: never
end
- return nil :: never
end
type Props = { [any]: any }
diff --git a/src/derive.luau b/src/derive.luau
index 49094d5..863fd98 100644
--- a/src/derive.luau
+++ b/src/derive.luau
@@ -4,11 +4,11 @@ local graph = require(script.Parent.graph)
local create_node = graph.create_node
local set_owner = graph.set_owner
local track = graph.track
-local get_owning_scope = graph.get_owning_scope
+local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node
local function derive(source: () -> T): () -> T
- local owner = get_owning_scope()
+ local owner = assert_owning_scope()
local node = create_node(false :: any, source)
diff --git a/src/effect.luau b/src/effect.luau
index 43b12ab..bbe1669 100644
--- a/src/effect.luau
+++ b/src/effect.luau
@@ -2,12 +2,12 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
local create_node = graph.create_node
-local get_owning_scope = graph.get_owning_scope
+local assert_owning_scope = graph.assert_owning_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_owning_scope()
+ local owner = assert_owning_scope()
local node = create_node(initial_value, callback)
diff --git a/src/flags.luau b/src/flags.luau
index 962301d..cc2d2f8 100644
--- a/src/flags.luau
+++ b/src/flags.luau
@@ -1 +1,7 @@
-return { strict = false }
+local function inline_test(): string
+ return debug.info(1, "n")
+end
+
+local is_O2 = inline_test() ~= "inline_test"
+
+return { strict = not is_O2, batch = false }
diff --git a/src/graph.luau b/src/graph.luau
index 77db497..2908e3d 100644
--- a/src/graph.luau
+++ b/src/graph.luau
@@ -12,47 +12,44 @@ export type Node = {
cache: T,
effect: ((T) -> T) | false,
cleanups: { () -> () } | false,
- parents: { owner: StartNode?, [number]: StartNode },
- [number]: Node
+
+ owned: { Node } | false,
+ owner: Node | false,
+
+ parents: { StartNode },
+ [number]: Node -- children
}
-- 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...) -> (), T...) -> (boolean, string?) do
- local t = { __mode = "kv" }
- setmetatable(t, t)
+local function ycall(fn: (T) -> U, arg: T): (boolean, string|U)
+ local thread = coroutine.create(pcall)
+ local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg)
- check_for_yield = function(fn, ...: any)
- local args = { ... }
-
- t.__unm = function(_)
- fn(unpack(args))
- end
-
- local ok, err: string? = pcall(function()
- local _ = -t
- end)
-
- return ok, if err == "attempt to yield across metamethod/C-call boundary"
- or err == "thread is not yieldable" then "yield occured"
- else err
+ assert(resume_ok)
+
+ if coroutine.status(thread) ~= "dead" then
+ return false, "attempt to yield in reactive scope"
end
+
+ return run_ok, result
end
local function get_scope(): Node?
return scopes[scopes.n]
end
-local function get_owning_scope(): Node
+local function assert_owning_scope(): Node
local scope = get_scope()
+
if not scope then
local caller_name = debug.info(2, "n")
- return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`)
+ return throw(`cannot use {caller_name}() in a non-reactive scope`)
elseif scope.effect then
- throw("owning scope is not stable; are you trying to derive a new source from within a side-effect?")
+ throw("cannot create new reactive scope in a tracking reactive scope")
end
+
return scope
end
@@ -62,8 +59,12 @@ local function add_child(parent: StartNode, child: Node)
end
local function set_owner(node: Node, owner: Node)
- node.parents.owner = owner
- table.insert(owner, node)
+ node.owner = owner
+ if owner.owned then
+ table.insert(owner.owned, node)
+ else
+ owner.owned = { node }
+ end
end
local function open_scope(node: Node)
@@ -96,19 +97,18 @@ local function run_cleanups(node: Node)
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
+local function find_and_swap_pop(t: { T }, v: T)
+ local idx = table.find(t, v) :: number
+ local n = #t
+ t[idx] = t[n]
+ t[n] = nil
end
local function unparent(node: Node)
local parents = node.parents
- for i, parent in ipairs(parents) do
- remove_child(parent, node)
+ for i, parent in next, parents do
+ find_and_swap_pop(parent, node)
parents[i] = nil
end
end
@@ -116,84 +116,121 @@ 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
+
+ if node.owner then
+ find_and_swap_pop(node.owner.owned :: { Node }, node)
+ node.owner = false
end
- while node[1] do destroy(node[1]) end
+ if node.owned then
+ local owned = node.owned
+ while owned[1] do destroy(owned[1]) end
+ end
end
-local update_queue = {} :: { Node }
+local function destroy_owned(node: Node)
+ if node.owned then
+ local owned = node.owned
+ while owned[1] do destroy(owned[1]) end
+ end
+end
+
+local update_queue = { n = 0 } :: { n: number, [number]: Node }
local function evaluate_node(node: Node)
local cur_value = node.cache
if flags.strict then
run_cleanups(node)
+ destroy_owned(node)
+
open_scope(node)
- local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value)
+ local ok, new_value = ycall(node.effect :: (T) -> T, cur_value)
close_scope()
- if not ok then throw(err :: string) end
+ if not ok then throw(new_value :: string) end
+
+ node.cache = new_value :: T
end
- run_cleanups(node) -- todo: move in scope?
+ run_cleanups(node)
+ destroy_owned(node)
+
open_scope(node)
- local ok, new_value = pcall(node.effect :: (T) -> T, cur_value)
+ local ok, new_value = pcall(node.effect :: (T) -> T, node.cache)
close_scope()
if not ok then
table.clear(update_queue)
+ update_queue.n = 0
throw(`side-effect error from source update\n{new_value}`)
end
node.cache = new_value
- return cur_value ~= new_value -- node has changed value
+ return cur_value ~= new_value
end
--- todo: case where owner is set from an untrack call within an effectful node, children clearing
-local function update_from(node: StartNode, n0: number)
- if not node[1] then return end
+local function queue_children(node: StartNode)
+ local i = update_queue.n
+ while node[1] do
+ i += 1
+ update_queue[i] = node[1]
+ unparent(node[1])
+ end
+ update_queue.n = i
+end
- local n = n0
+local _flushing = false
+local function flush_update_queue()
+ assert(not _flushing, "recursive queue flush occured") -- todo
+ _flushing = true
- -- unparent all children and queue for eval
- do
- local child = node[1]
- while child do -- todo: case where child in owner context
- unparent(child)
+ local n0 = 0
- n += 1
- update_queue[n] = child
+ local i = n0 + 1
+ while i <= update_queue.n do
+ local node = update_queue[i]
+ --assert(node.effect)
- child = node[1]
+ if node.owner and evaluate_node(node) then
+ queue_children(node)
end
+
+ update_queue[i] = false :: any
+ i += 1
end
- -- evaluate all queued children
- for i = n0 + 1, n do
- local child = update_queue[i] -- todo: error: index boolean
- if not child.effect then continue end
+ update_queue.n = n0
- if evaluate_node(child) then
- update_from(child, n)
+ _flushing = false
+end
+
+local function update(root: StartNode)
+ local n0 = update_queue.n
+ queue_children(root)
+
+ if flags.batch then return end
+
+ local i = n0 + 1
+ while i <= update_queue.n do
+ local node = update_queue[i]
+ --assert(node.effect)
+
+ -- check if node is still owned in case destroyed after queued
+ if node.owner and evaluate_node(node) then
+ queue_children(node)
end
update_queue[i] = false :: any -- false instead of nil to avoid sparse
+ i += 1
end
-end
-local function update(node: StartNode)
- update_from(node, 0)
+ update_queue.n = n0
end
local function track(node: StartNode)
@@ -208,6 +245,10 @@ local function create_node(value: T, effect: false | (T) -> T): Node
cache = value,
effect = effect,
cleanups = false,
+
+ owner = false,
+ owned = false,
+
parents = {},
}
end
@@ -225,7 +266,7 @@ return table.freeze {
close_scope = close_scope,
evaluate_node = evaluate_node,
get_scope = get_scope,
- get_owning_scope = get_owning_scope,
+ assert_owning_scope = assert_owning_scope,
add_cleanup = add_cleanup,
set_owner = set_owner,
destroy = destroy,
@@ -236,5 +277,6 @@ return table.freeze {
create_node = create_node,
create_start_node = create_start_node,
get_children = get_children,
+ flush_update_queue = flush_update_queue,
scopes = scopes
}
diff --git a/src/init.luau b/src/init.luau
index 8958dd9..840f7f9 100644
--- a/src/init.luau
+++ b/src/init.luau
@@ -1,6 +1,6 @@
--------------------------------------------------------------------------------
-- vide.luau
--- v0.1.0
+-- v0.2.0
--------------------------------------------------------------------------------
if not game then script = require "test/relative-string" end
@@ -11,10 +11,13 @@ local create = require(script.create)
local apply = require(script.apply)
local source = require(script.source)
local effect = require(script.effect)
+local derive = require(script.derive)
local cleanup = require(script.cleanup)
local untrack = require(script.untrack)
-local derive = require(script.derive)
+local read = require(script.read)
+local batch = require(script.batch)
local switch = require(script.switch)
+local show = require(script.show)
local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)()
local action = require(script.action)()
@@ -51,12 +54,15 @@ local vide = {
effect = effect,
derive = derive,
switch = switch,
+ show = show,
indexes = indexes,
values = values,
-- util
cleanup = cleanup,
untrack = untrack,
+ read = read,
+ batch = batch,
-- animations
spring = spring,
@@ -86,28 +92,22 @@ local vide = {
end
}
-do
- local set = false
-
- setmetatable(vide :: any, {
- __index = function(_, index: unknown): ()
- if index == "strict" then
- return flags.strict
- else
- throw(`{tostring(index)} is not a valid member of vide`)
- end
- end,
-
- __newindex = function(_, index: unknown, value: unknown)
- if index == "strict" then
- if set then throw "strict mode has already been set" end
- set = true
- flags.strict = value :: boolean
- else
- throw(`{tostring(index)} is not a valid member of vide`)
- end
+setmetatable(vide :: any, {
+ __index = function(_, index: unknown): ()
+ if index == "strict" then
+ return flags.strict
+ else
+ throw(`{tostring(index)} is not a valid member of vide`)
end
- })
-end
+ end,
+
+ __newindex = function(_, index: unknown, value: unknown)
+ if index == "strict" then
+ flags.strict = value :: boolean
+ else
+ throw(`{tostring(index)} is not a valid member of vide`)
+ end
+ end
+})
return vide
diff --git a/src/maps.luau b/src/maps.luau
index 1649039..ef36209 100644
--- a/src/maps.luau
+++ b/src/maps.luau
@@ -10,7 +10,7 @@ local create_start_node = graph.create_start_node
local set_owner = graph.set_owner
local track = graph.track
local update = graph.update
-local get_owning_scope = graph.get_owning_scope
+local assert_owning_scope = graph.assert_owning_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local evaluate_node = graph.evaluate_node
@@ -28,7 +28,7 @@ local function check_primitives(t: {})
end
local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO }
- local owner = get_owning_scope()
+ local owner = assert_owning_scope()
local subowner = create_node(false, false)
set_owner(subowner, owner)
@@ -112,6 +112,7 @@ local function indexes(input: () -> Map, transform: (() -> VI,
local node = create_node(false :: any, function()
return update_children(input())
end)
+ set_owner(node, owner)
evaluate_node(node)
@@ -122,7 +123,7 @@ local function indexes(input: () -> Map, transform: (() -> VI,
end
local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO }
- local owner = get_owning_scope()
+ local owner = assert_owning_scope()
local subowner = create_node(false, false)
set_owner(subowner, owner)
@@ -214,6 +215,7 @@ local function values(input: () -> Map, transform: (VI, () ->
local node = create_node(false :: any, function()
return update_children(input())
end)
+ set_owner(node, owner)
evaluate_node(node)
diff --git a/src/memoize.luau b/src/memoize.luau
deleted file mode 100644
index cf83427..0000000
--- a/src/memoize.luau
+++ /dev/null
@@ -1,17 +0,0 @@
-local function memoize(f: (X) -> Y): (X) -> Y
- local cache: { [X]: Y? } = {}
-
- return function(x: X): Y
- local y = cache[x]
-
- if not y then
- y = f(x)
- cache[x] = y
- end
-
- return y :: Y
- end
-end
-
-return memoize
-
diff --git a/src/read.luau b/src/read.luau
new file mode 100644
index 0000000..d3a2fb7
--- /dev/null
+++ b/src/read.luau
@@ -0,0 +1,7 @@
+if not game then script = require "test/relative-string" end
+
+local function read(value: T | () -> T): T
+ return if type(value) == "function" then value() else value
+end
+
+return read
diff --git a/src/show.luau b/src/show.luau
new file mode 100644
index 0000000..1cf60ca
--- /dev/null
+++ b/src/show.luau
@@ -0,0 +1,18 @@
+if not game then script = require "test/relative-string" end
+
+local switch = require(script.Parent.switch)
+
+local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T?
+ local function truthy()
+ return not not source()
+ end
+
+ return switch(truthy) {
+ [true] = component,
+ [false] = fallback,
+ }
+end
+
+return show ::
+ ((source: () -> any, component: () -> T) -> () -> T?) &
+ ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?)
diff --git a/src/source.luau b/src/source.luau
index f1307fc..dd633bb 100644
--- a/src/source.luau
+++ b/src/source.luau
@@ -6,7 +6,7 @@ local create_start_node = graph.create_start_node
local track = graph.track
local update = graph.update
-export type Source = (() -> T) & ((T) -> T)
+export type Source = (() -> T) & ((value: T) -> T)
local function source(initial_value: T): Source
local node = create_start_node(initial_value)
diff --git a/src/spring.luau b/src/spring.luau
index 01797ad..cc92d9a 100644
--- a/src/spring.luau
+++ b/src/spring.luau
@@ -27,7 +27,7 @@ type Node = graph.Node
type StartNode = graph.StartNode
local create_node = graph.create_node
local create_start_node = graph.create_start_node
-local get_owning_scope = graph.get_owning_scope
+local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node
local update = graph.update
local set_owner = graph.set_owner
@@ -150,7 +150,7 @@ local springs: { [SpringData]: StartNode } = {}
setmetatable(springs, { __mode = "v" })
local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T
- local owner = get_owning_scope()
+ local owner = assert_owning_scope()
-- https://en.wikipedia.org/wiki/Damping
@@ -161,6 +161,12 @@ local function spring(source: () -> T, period: number?, damping_ratio: number
local c_c = 2*w_n
local c = z * c_c
+ -- todo: is there a solution other than reducing step size?
+ -- todo: this does not catch all solver exploding cases
+ if c > UPDATE_RATE*2 then -- solver will explode if this is true
+ throw("spring damping too high, consider reducing damping or increasing period")
+ end
+
local data: SpringData = {
k = k,
c = c,
diff --git a/src/switch.luau b/src/switch.luau
index 421d583..12fd376 100644
--- a/src/switch.luau
+++ b/src/switch.luau
@@ -9,14 +9,14 @@ local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local track = graph.track
local destroy = graph.destroy
-local get_owning_scope = graph.get_owning_scope
+local assert_owning_scope = graph.assert_owning_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?
- local owner = get_owning_scope()
+ local owner = assert_owning_scope()
return function(map)
local last_scope: Node?
@@ -35,7 +35,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> ()
if component == nil then return nil end
if type(component) ~= "function" then
- throw("map must map a value to a function")
+ throw "map must map a value to a function"
end
local new_scope = create_node(false, false)
@@ -53,7 +53,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> ()
return result
end
- local node = create_node(nil :: any, update)
+ local node = create_node(nil :: U?, update)
set_owner(node, owner)
evaluate_node(node)
diff --git a/src/throw.luau b/src/throw.luau
index d3ea687..70b7973 100644
--- a/src/throw.luau
+++ b/src/throw.luau
@@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end
local trace = require(script.Parent.trace)
local function throw(msg): any
- error(msg, trace()-1)
+ error(msg, trace() - 1)
end
return throw
diff --git a/src/untrack.luau b/src/untrack.luau
index 230674d..86cdb7b 100644
--- a/src/untrack.luau
+++ b/src/untrack.luau
@@ -1,27 +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 get_scope = graph.get_scope
local function untrack(source: () -> T): T
local scope = get_scope()
- if not scope then
- throw("cannot untrack in non-reactive scope")
- end; assert(scope)
+
+ if scope then
+ -- sources are only tracked if the node in scope has an effect
+ local effect = scope.effect
+ scope.effect = false
- -- sources are only tracked if the node in scope has an effect
- local effect = scope.effect
- scope.effect = false
+ local ok, result = pcall(source)
- local ok, result = pcall(source)
+ scope.effect = effect :: () -> ()
- scope.effect = effect :: () -> ()
+ if not ok then error(result, 0) end
- if not ok then error(result, 0) end
-
- return result
+ return result
+ else
+ return source()
+ end
end
return untrack
diff --git a/test/benchmark.luau b/test/benchmark.luau
index 6702235..bee8c68 100644
--- a/test/benchmark.luau
+++ b/test/benchmark.luau
@@ -6,26 +6,30 @@ local source = vide.source
local derive = vide.derive
local indexes = vide.indexes
local values = vide.values
+local batch = vide.batch
local cleanup = vide.cleanup
local create = vide.create
+assert(not vide.strict)
+
local function TITLE(name: string)
print()
print(testkit.color.white(name))
end
-local N = 2^18 -- 262144
-
-local function WRAP_BENCH(name: string, fn: () -> ())
+local function ROOT_BENCH(name: string, fn: () -> ())
vide.root(function(destroy)
BENCH(name, fn)
return destroy
end)()
end
+local N = 2^18 -- 262144
+
+
TITLE "sources"
-WRAP_BENCH("create source", function()
+BENCH("create source", function()
local cache = table.create(N)
for i = 1, START(N) do
@@ -33,7 +37,7 @@ WRAP_BENCH("create source", function()
end
end)
-WRAP_BENCH("get value", function()
+BENCH("get value", function()
local src = source(1)
for i = 1, START(N) do
@@ -41,7 +45,7 @@ WRAP_BENCH("get value", function()
end
end)
-WRAP_BENCH("set value", function()
+BENCH("set value", function()
local src = source(1)
for i = 1, START(N) do
@@ -49,7 +53,7 @@ WRAP_BENCH("set value", function()
end
end)
-WRAP_BENCH("derive 1 source", function()
+ROOT_BENCH("derive 1 source", function()
local cache = table.create(N)
local src = source(1)
@@ -60,7 +64,7 @@ WRAP_BENCH("derive 1 source", function()
end
end)
-WRAP_BENCH("derive 4 sources", function()
+ROOT_BENCH("derive 4 sources", function()
local cache = table.create(N)
local src = vide.source(1)
local src2 = vide.source(2)
@@ -76,7 +80,7 @@ end)
TITLE "graphs"
-WRAP_BENCH("update 1->1 graph", function()
+ROOT_BENCH("update 1->1 graph", function()
local src = source(1)
local _derived = derive(function() return src() end)
@@ -86,7 +90,7 @@ WRAP_BENCH("update 1->1 graph", function()
end
end)
-WRAP_BENCH("update 1->1 graph with cleanup", function()
+ROOT_BENCH("update 1->1 graph with cleanup", function()
local src = source(1)
derive(function()
@@ -99,7 +103,7 @@ WRAP_BENCH("update 1->1 graph with cleanup", function()
end
end)
-WRAP_BENCH("update 1->1000 graph", function()
+ROOT_BENCH("update 1->1000 graph", function()
local src = source(-1)
for i = 1, 1000 do
@@ -113,7 +117,7 @@ WRAP_BENCH("update 1->1000 graph", function()
end
end)
-WRAP_BENCH("update 1->1->1->1...1000 graph", function()
+ROOT_BENCH("update 1->1->1->1...1000 graph", function()
local src = source(-1)
local last = src
@@ -129,8 +133,28 @@ WRAP_BENCH("update 1->1->1->1...1000 graph", function()
end
end)
--- todo: repeat with batching
-WRAP_BENCH("update 1000->1 graph", function()
+-- todo: why does it hang at 1k? it didn't before
+ROOT_BENCH("update 500->1 graph", function()
+ local srcs = {}
+ for i = 1, 500 do
+ srcs[i] = source(0)
+ end
+
+ derive(function()
+ for i = 1, 500 do
+ srcs[i]()
+ end
+ return false
+ end)
+
+ for i = 1, START(1) do
+ for idx = 1, 500 do
+ srcs[idx](i)
+ end
+ end
+end)
+
+ROOT_BENCH("update 1000->1 graph (batched)", function()
local srcs = {}
for i = 1, 1000 do
srcs[i] = source(0)
@@ -144,14 +168,16 @@ WRAP_BENCH("update 1000->1 graph", function()
end)
for i = 1, START(1) do
- for idx = 1, 1000 do
- srcs[idx](i)
- end
+ batch(function()
+ for idx = 1, 1000 do
+ srcs[idx](i)
+ end
+ end)
end
end)
--- todo: optimize, repeat with batching
-WRAP_BENCH("update 1000x 1->1 common extern. graph", function()
+-- todo: optimize this case
+ROOT_BENCH("update 1000 1->1 common extern. graph", function()
local ext = source(-1)
local srcs = {}
@@ -171,7 +197,7 @@ end)
TITLE "property apply"
-WRAP_BENCH("apply 0 properties", function()
+ROOT_BENCH("apply 0 properties", function()
local apply = require "src/apply"
local instance = create("Frame") {}
@@ -180,7 +206,7 @@ WRAP_BENCH("apply 0 properties", function()
end
end)
-WRAP_BENCH("apply 8 properties", function()
+ROOT_BENCH("apply 8 properties", function()
local apply = require "src/apply"
local instance = create("Frame") {}
@@ -198,7 +224,7 @@ WRAP_BENCH("apply 8 properties", function()
end
end)
-WRAP_BENCH("bind property", function()
+ROOT_BENCH("bind property", function()
local apply = require "src/apply"
local instance = create("Frame") {}
@@ -213,7 +239,7 @@ WRAP_BENCH("bind property", function()
return nil
end)
-WRAP_BENCH("update binding", function()
+ROOT_BENCH("update binding", function()
local apply = require "src/apply"
local instance = create("Frame") {}
@@ -230,11 +256,29 @@ WRAP_BENCH("update binding", function()
return nil
end)
+TITLE "switch()"
+
+ROOT_BENCH("switch()", function()
+ local M = 2^8
+
+ local map = {}
+ for i = 1, M do
+ map[i] = function() return i end
+ end
+
+ local input = source(0)
+ vide.switch(input)(map)
+
+ for i = 1, START(N) do
+ input(bit32.band(i, M - 1) + 1) -- i % m + 1
+ end
+end)
+
TITLE "indexes()"
N /= 1024
-WRAP_BENCH("indexes() all new", function()
+ROOT_BENCH("indexes() all new", function()
local data = {}
for i = 1, N do
@@ -252,7 +296,7 @@ WRAP_BENCH("indexes() all new", function()
return nil
end)
-WRAP_BENCH("indexes() no change", function()
+ROOT_BENCH("indexes() no change", function()
local data = {}
for i = 1, N do
@@ -272,7 +316,7 @@ WRAP_BENCH("indexes() no change", function()
return nil
end)
-WRAP_BENCH("indexes() all change", function()
+ROOT_BENCH("indexes() all change", function()
local data = {}
for i = 1, N do
@@ -296,7 +340,7 @@ WRAP_BENCH("indexes() all change", function()
src(data)
end)
-WRAP_BENCH("indexes() all remove", function()
+ROOT_BENCH("indexes() all remove", function()
local data = {}
for i = 1, N do
@@ -320,7 +364,7 @@ end)
TITLE "values()"
-WRAP_BENCH("values() all new", function()
+ROOT_BENCH("values() all new", function()
local data = {}
for i = 1, N do
@@ -338,7 +382,7 @@ WRAP_BENCH("values() all new", function()
return nil
end)
-WRAP_BENCH("values() no change", function()
+ROOT_BENCH("values() no change", function()
local data = {}
for i = 1, N do
@@ -358,7 +402,7 @@ WRAP_BENCH("values() no change", function()
src(data)
end)
-WRAP_BENCH("values() all change", function()
+ROOT_BENCH("values() all change", function()
local data = {}
for i = 1, N do
@@ -383,7 +427,7 @@ WRAP_BENCH("values() all change", function()
src(data)
end)
-WRAP_BENCH("values() all remove", function()
+ROOT_BENCH("values() all remove", function()
local data = {}
for i = 1, N do
@@ -407,7 +451,7 @@ N *= 1024
TITLE "cleanup"
-WRAP_BENCH("register new cleanup", function()
+ROOT_BENCH("register new cleanup", function()
local cleanup = cleanup
local cleaner = function() end
@@ -431,7 +475,7 @@ TITLE "aggregate"
do
-- the purpose of the two following benchmarks is to measure the overhead of
-- aggregate construction
- WRAP_BENCH("set explicit mock vector2", function()
+ ROOT_BENCH("set explicit mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@@ -446,7 +490,7 @@ do
end
end)
- WRAP_BENCH("set aggregate mock vector2", function()
+ ROOT_BENCH("set aggregate mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@@ -465,7 +509,7 @@ end
-- innacurate due to no Vector3 in vanilla Luau
-- mock vector is 200x slower than native vector
--- WRAP_BENCH("spring update", function()
+-- ROOT_BENCH("spring update", function()
-- local root, source, spring = vide.root, vide.source, vide.spring
-- local src = source(0)
@@ -485,7 +529,7 @@ end
-- N /= 1024
--- WRAP_BENCH("spring step", function()
+-- ROOT_BENCH("spring step", function()
-- local root, source, spring = vide.root, vide.source, vide.spring
-- local src = source(0)
diff --git a/test/spring-test.luau b/test/spring-test.luau
index c22d955..2e3bc9b 100644
--- a/test/spring-test.luau
+++ b/test/spring-test.luau
@@ -48,14 +48,14 @@ local function main()
local reset = "\27[H\27[2J" -- ANSI clear terminal
local offset = string.rep("\n", MAX - fv + OFFSET)
local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv))
- print(reset .. offset .. bar)
+ print(reset .. offset .. bar .. "\n" .. v)
end)
- local elapsed = 0
+ local T = 3
+ local elapsed = T/1.2
repeat local dt = step()
vide.step(dt)
- local T = 3
elapsed += dt
while elapsed >= T do
elapsed -= T
diff --git a/test/testkit.luau b/test/testkit.luau
index 544c7cc..31876eb 100644
--- a/test/testkit.luau
+++ b/test/testkit.luau
@@ -1,6 +1,6 @@
--------------------------------------------------------------------------------
-- testkit.luau
--- v0.7.1
+-- v0.7.2
--------------------------------------------------------------------------------
local color = {
@@ -323,7 +323,7 @@ end
local function print2(v: unknown)
type Buffer = { n: number, [number]: string }
- type Cyclic = { [{}]: true }
+ type Cyclic = { n: number, [{}]: number }
-- overkill concatenationless string buffer
local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic)
@@ -347,16 +347,19 @@ local function print2(v: unknown)
else -- is table
local tabbed_indent = indent .. TAB
- str.n += 1
-
if cyclic[value] then
- str[str.n] = color.gray "*cyclic reference*"
+ str.n += 1
+ str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`)
return
else
- cyclic[value] = true
+ cyclic.n += 1
+ cyclic[value] = cyclic.n
end
- str[str.n] = "{\n"
+ str.n += 3
+ str[str.n - 2] = "{ "
+ str[str.n - 1] = color.gray(tostring(cyclic[value]))
+ str[str.n - 0] = "\n"
local i, v = next(value, nil)
while v ~= nil do
@@ -393,7 +396,7 @@ local function print2(v: unknown)
end
local str = { n = 0 }
- local cyclic = {}
+ local cyclic = { n = 0 }
tos(v, 0, str, cyclic)
print(table.concat(str))
end
@@ -455,7 +458,7 @@ return {
return BENCH, START
end,
- print2 = print2,
+ print = print2,
seq = shallow_eq,
deq = deep_eq,
diff --git a/test/tests.luau b/test/tests.luau
index c8844f8..c38c6a7 100644
--- a/test/tests.luau
+++ b/test/tests.luau
@@ -31,6 +31,8 @@ end
local NIL = nil :: any
+vide.strict = false
+
TEST("graph", function()
local create_node = graph.create_node
local track = graph.track
@@ -39,6 +41,7 @@ TEST("graph", function()
local get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
+ local set_owner = graph.set_owner
local get_children = graph.get_children
local add_cleanup = graph.add_cleanup
local destroy = graph.destroy
@@ -73,10 +76,14 @@ TEST("graph", function()
end
do CASE "rerun linked nodes"
+ local root = node()
local a = node()
local b = node()
local c = node()
+ set_owner(b, root)
+ set_owner(c, root)
+
local count = 0
local function effect(x)
@@ -102,8 +109,15 @@ TEST("graph", function()
end
do CASE "diamond graph"
+ -- a -> b -> d
+ -- -> c
+ local root = node()
local a, b, c, d = node(), node(), node(), node()
+ set_owner(b, root)
+ set_owner(c, root)
+ set_owner(d, root)
+
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
@@ -121,15 +135,20 @@ TEST("graph", function()
end
do CASE "duplicate child on rerun"
+ local root = node()
local a, b, c = node(), node(), node()
+ set_owner(a, root)
+ set_owner(b, root)
+ set_owner(c, root)
+
function c.effect(x)
track(a)
track(b)
return not x
end
- open_scope(c); assert(c.effect)(NIL); close_scope()
+ open_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); close_scope()
update(a)
@@ -166,28 +185,28 @@ TEST("graph", function()
items_updated = node()
track(items_updated) -- should not
- add_child(root, items_updated)
+ set_owner(items_updated, root)
do open_scope(items_updated)
track(items)
do open_scope(root)
- add_child(root, scope1)
+ set_owner(scope1, root)
do open_scope(scope1)
clean "scope1"
bind1 = node()
- add_child(scope1, bind1)
+ set_owner(bind1, scope1)
do open_scope(bind1)
clean "bind1"
track(selected)
close_scope() end
close_scope() end
- add_child(root, scope2)
+ set_owner(scope2, root)
do open_scope(scope2)
clean "scope2"
bind2 = node()
- add_child(scope2, bind2)
+ set_owner(bind2, scope2)
do open_scope(bind2)
clean "bind2"
track(selected)
@@ -207,10 +226,10 @@ TEST("graph", function()
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))
+ CHECK(#c == 0)
+ -- CHECK(table.find(c, items_updated))
+ -- CHECK(table.find(c, scope1 :: Node))
+ -- CHECK(table.find(c, scope2 :: Node))
end
do
@@ -222,19 +241,19 @@ TEST("graph", function()
do
local c = get_children(scope1)
- CHECK(#c == 1)
- CHECK(table.find(c, bind1))
+ CHECK(#c == 0)
+ --CHECK(table.find(c, bind1))
end
do
local c = get_children(scope2)
- CHECK(#c == 1)
- CHECK(table.find(c, bind2))
+ CHECK(#c == 0)
+ --CHECK(table.find(c, bind2))
end
-- destroy
- CHECK(table.find(get_children(root), scope1 :: Node))
+ --CHECK(table.find(get_children(root), scope1 :: Node))
destroy(scope1)
CHECK(cleaned.scope1)
@@ -243,7 +262,7 @@ TEST("graph", function()
bind1 = NIL
bind2 = NIL
gc()
- CHECK(#get_children(root) == 2)
+ CHECK(#get_children(root) == 0)
CHECK(#get_children(selected) == 1)
end
@@ -253,6 +272,55 @@ TEST("graph", function()
gc()
CHECK(not wref[1])
end
+
+ do CASE "recursive update"
+ --[[
+
+ A -> B + C
+ D -> E + F
+
+ B updates D
+
+ depth=1
+ B, C
+ ^
+
+ depth=2
+ E, F
+ ^
+
+ depth=2
+ _, F
+ ^
+
+ depth=1
+ _, _ <- attempt to update nothing
+ ^
+
+ ]]
+
+ local a, b, c, d, e, f = node(), node(), node(), node(), node(), node()
+
+ local root = node()
+ set_owner(a, root)
+ set_owner(b, root)
+ set_owner(c, root)
+ set_owner(d, root)
+ set_owner(e, root)
+ set_owner(f, root)
+
+ function b.effect(x)
+ update(d)
+ return not x
+ end
+
+ add_child(a, b); add_child(a, c)
+ add_child(d, e); add_child(d, f)
+
+ update(a)
+
+ CHECK(true)
+ end
end)
TEST("mount()", function()
@@ -480,6 +548,44 @@ TEST("derive()", wrap_root(function()
CHECK(count == 2)
end
+ -- do CASE "behavior of effect within an effect"
+ -- local num = source(1)
+
+ -- local ran = table.create(100, 0)
+ -- local cleaned = table.create(100, 0)
+
+ -- local destroy = vide.mount(function()
+ -- local owner = derive(function()
+ -- local i = num()
+
+ -- return untrack(function()
+ -- return derive(function()
+ -- ran[i] += 1
+ -- cleanup(function()
+ -- cleaned[i] += 1
+ -- end)
+ -- return i
+ -- end)
+ -- end)
+ -- end)
+
+ -- local child1 = owner()
+ -- num(2)
+ -- CHECK(cleaned[1] == 1)
+ -- local child2 = owner()
+
+ -- CHECK(child1() == 1)
+ -- CHECK(child2() == 2)
+ -- end)
+
+ -- destroy()
+
+ -- CHECK(ran[1] == 1)
+ -- CHECK(ran[2] == 1)
+ -- CHECK(cleaned[1] == 1)
+ -- CHECK(cleaned[2] == 1)
+ -- end
+
do CASE "garbage collection"
-- check that `b` does not allow gc of `a`
local a = source(1)
@@ -554,6 +660,7 @@ TEST("effect()", wrap_root(function()
end))
TEST("cleanup()", wrap_root(function()
+ local root = vide.root
local source = vide.source
local effect = vide.effect
local cleanup = vide.cleanup
@@ -612,6 +719,25 @@ TEST("cleanup()", wrap_root(function()
src(3)
CHECK(testkit.seq(queue, { 1, 2, 1, 2 }))
end
+
+ do CASE "cleanup objects"
+ local ran = {}
+
+ root(function(destroy)
+ effect(function()
+ cleanup { disconnect = function() ran.disconnect = true end }
+ cleanup { Disconnect = function() ran.Disconnect = true end }
+ cleanup { destroy = function() ran.destroy = true end }
+ cleanup { Destroy = function() ran.Destroy = true end }
+ destroy()
+ end)
+ end)
+
+ CHECK(ran.disconnect)
+ CHECK(ran.Disconnect)
+ CHECK(ran.destroy)
+ CHECK(ran.Destroy)
+ end
end))
TEST("create()", wrap_root(function()
@@ -816,6 +942,33 @@ TEST("create()", wrap_root(function()
CHECK(not wref[1])
end
+ do CASE "recursive create"
+ local set_test_to_true = vide.action(function(self) (self :: any).test = true end)
+
+ local f2
+
+ local to_apply = {
+ { a = 1 },
+ set_test_to_true,
+ b = function() f2 = create "Frame" { a = 2 } end,
+ } :: { [number|string]: unknown }
+
+ -- do -- confirm iteration order
+ -- local t = {}
+ -- for i in to_apply do
+ -- table.insert(t, i)
+ -- end
+ -- assert(t[1] == "a")
+ -- end
+
+ local f = create "Frame" (to_apply)
+
+ CHECK((f :: any).a == 1)
+ CHECK((f :: any).test == true )
+
+ CHECK((f2 :: any).a == 2)
+ end
+
do CASE "garbage collection test"
local wref
@@ -843,6 +996,76 @@ TEST("create()", wrap_root(function()
end
end))
+TEST("show()", wrap_root(function()
+ local untrack = vide.untrack
+ local cleanup = vide.cleanup
+ local source = vide.source
+ local effect = vide.effect
+ local show = vide.show
+ local root = vide.root
+
+ do CASE "main"
+ -- uses switch() internally, more extensive testing of scoping not needed
+ local value = source("truey" :: unknown)
+ local function one() return 1 end
+ local function two() return 2 end
+
+ local output = show(value, one, two)
+
+ CHECK(output() == 1)
+ value(nil)
+ CHECK(output() == 2)
+ end
+
+ do CASE "alt"
+ local visible = vide.source(true)
+ local count = vide.source(0)
+
+ local outer = 0
+ local inner = 0
+ local destroyed = 0
+
+ root(function()
+ effect(function()
+ visible()
+ outer += 1
+
+ untrack(function()
+ effect(function()
+ count()
+
+ inner += 1
+
+ cleanup(function()
+ destroyed += 1
+ end)
+ end)
+ return nil
+ end)
+ end)
+ end)
+
+ CHECK(outer == 1)
+ CHECK(inner == 1)
+ CHECK(destroyed == 0)
+
+ count(count() + 1)
+ CHECK(outer == 1)
+ CHECK(inner == 2)
+ CHECK(destroyed == 1)
+
+ visible(false)
+ CHECK(outer == 2)
+ CHECK(inner == 3)
+ CHECK(destroyed == 2)
+
+ count(count() + 1)
+ CHECK(outer == 2)
+ CHECK(inner == 4)
+ CHECK(destroyed == 3)
+ end
+end))
+
TEST("switch()", wrap_root(function()
local source = vide.source
local switch = vide.switch
@@ -951,11 +1174,28 @@ TEST("switch()", wrap_root(function()
CHECK(n0 == n1)
end
+
+ do CASE "strict"
+ vide.strict = true
+
+ local input = source(0)
+ local output = switch(input) {
+ [0] = function() return 0 end,
+ [1] = function() return 1 end,
+ }
+
+ CHECK(output() == 0)
+ input(1)
+ CHECK(output() == 1)
+
+ vide.strict = false
+ end
end))
TEST("indexes()", wrap_root(function()
local create = vide.create
local source = vide.source
+ local effect = vide.effect
local indexes = vide.indexes
local cleanup = vide.cleanup
@@ -976,9 +1216,13 @@ TEST("indexes()", wrap_root(function()
local count = table.create(3, 0)
- local output = indexes(input, function(v, i)
- count[i] += 1
- return v
+ local output = vide.root(function()
+ local output = indexes(input, function(v, i)
+ count[i] += 1
+ return v
+ end)
+
+ return output
end)
input { 1, 2, 4 }
@@ -1091,6 +1335,59 @@ TEST("indexes()", wrap_root(function()
CHECK(n0 == n1)
end
+
+ -- practical example based on the graph - recursive update test
+ do CASE "recursive update"
+ local items = source { 1 }
+
+ local updated = table.create(100, 0)
+
+ indexes(items, function(item)
+ effect(function()
+ item()
+ updated[1] += 1
+ end)
+
+ effect(function()
+ item()
+ updated[2] += 1
+ end)
+
+ return {}
+ end)
+
+ effect(function()
+ items()
+ updated[3] += 1
+ end)
+
+ effect(function()
+ items()
+ updated[4] += 1
+ end)
+
+ items { 2 }
+
+ CHECK(updated[1] == 2)
+ CHECK(updated[2] == 2)
+ CHECK(updated[3] == 2)
+ CHECK(updated[4] == 2)
+ end
+
+ do CASE "strict"
+ vide.strict = true
+
+ local input = source{1}
+ local output = indexes(input, function(v)
+ return { v }
+ end)
+
+ CHECK(output()[1][1]() == 1)
+ input{2}
+ CHECK(output()[1][1]() == 2)
+
+ vide.strict = false
+ end
end))
TEST("values()", wrap_root(function()
@@ -1321,12 +1618,12 @@ TEST("spring()", wrap_root(function()
end))
TEST("untrack()", wrap_root(function()
- local root = vide.root
local source = vide.source
- local derive = vide.derive
local effect = vide.effect
- local cleanup = vide.cleanup
+ local derive = vide.derive
local untrack = vide.untrack
+ local cleanup = vide.cleanup
+ local root = vide.root
do CASE "does not register dependency"
local a = source(0)
@@ -1505,7 +1802,7 @@ TEST("changed()", wrap_root(function()
changed("Text", output)
}
- --CHECK(output() == "a")
+ CHECK(output() == "a")
text.Text = "b"
CHECK(output() == "b")
end
@@ -1530,6 +1827,338 @@ TEST("changed()", wrap_root(function()
end
end))
+TEST("batch()", wrap_root(function()
+ local source = vide.source
+ local derive = vide.derive
+ local batch = vide.batch
+
+ do CASE "evaluation deferred"
+ local a = source(0)
+
+ local count = { b = 0, b2 = 0, c = 0 }
+
+ local b = derive(function()
+ count.b += 1
+ return a() + 1
+ end)
+
+ local b2 = derive(function()
+ count.b2 += 1
+ return a() + 2
+ end)
+
+ local c = derive(function()
+ count.c += 1
+ return b() + b2()
+ end)
+
+ batch(function()
+ a(1)
+ CHECK(count.b == 1)
+ CHECK(count.b2 == 1)
+ CHECK(count.c == 1)
+ end)
+
+ CHECK(count.b == 2)
+ CHECK(count.b2 == 2)
+ CHECK(count.c == 2)
+
+ CHECK(b() == 2)
+ CHECK(c() == 5)
+ end
+
+ do CASE "recursive call"
+ local a1 = source(0)
+ local a2 = source(0)
+ local a3 = source(0)
+
+ local count = { b1 = 0, b2 = 0, b3 = 0 }
+
+ local b1 = derive(function()
+ count.b1 += 1
+ return a1() + 1
+ end)
+
+ local b2 = derive(function()
+ count.b2 += 1
+ return a2() + 1
+ end)
+
+ local b3 = derive(function()
+ count.b3 += 1
+ return a3() + 1
+ end)
+
+ batch(function()
+ a1(1)
+ batch(function()
+ a2(2)
+ end)
+ a3(3)
+ CHECK(count.b1 == 1)
+ CHECK(count.b2 == 1)
+ CHECK(count.b3 == 1)
+ end)
+
+ CHECK(count.b1 == 2)
+ CHECK(count.b2 == 2)
+ CHECK(count.b3 == 2)
+
+ CHECK(b1() == 2)
+ CHECK(b2() == 3)
+ CHECK(b3() == 4)
+ end
+end))
+
+TEST("read()", wrap_root(function()
+ local source = vide.source
+ local effect = vide.effect
+ local read = vide.read :: any -- todo
+
+ do CASE "read primitive"
+ CHECK(read(1) == 1)
+ end
+
+ do CASE "read source"
+ local src = source(1) :: () -> number
+ CHECK(read(src) == 1)
+ end
+
+ do CASE "track source"
+ local src = source(0)
+
+ local count = 0
+ effect(function()
+ read(src)
+ count += 1
+ end)
+
+ src(1)
+ CHECK(count == 2)
+ end
+end))
+
+TEST("nested effects cases", function()
+ local vide = require "src/init"
+ local source = vide.source
+ local effect = vide.effect
+ local untrack = vide.untrack
+ local cleanup = vide.cleanup
+ local root = vide.root
+
+ local ran = 0
+ local cleaned = 0
+
+ local function Count()
+ local count = source(0)
+
+ effect(function()
+ count()
+ ran += 1
+ cleanup(function() cleaned += 1 end)
+ end)
+
+ return nil
+ end
+
+ local function App(destroy)
+ local name = source "a"
+
+ effect(function()
+ name()
+ untrack(Count)
+ end)
+
+ CHECK(ran == 1)
+ CHECK(cleaned == 0)
+
+ name "b"
+
+ CHECK(ran == 2)
+ CHECK(cleaned == 1)
+
+ destroy()
+
+ CHECK(ran == 2)
+ CHECK(cleaned == 2)
+ end
+
+ root(App)
+end)
+
+TEST("graph edge cases", wrap_root(function()
+ local source = vide.source
+ local derive = vide.derive
+ local effect = vide.effect
+ local root = vide.root
+
+ do CASE "diamond A,B,C,D"
+ --[[
+
+ a > b > d
+ > c >
+
+ ]]
+
+ local a = source(0)
+
+ local b = derive(function() return (a() % 2 == 0) and 1 or 0 end)
+ local c = derive(function() return a() * 2 end)
+ local d = derive(function() return b() + c() end)
+
+ local count = { b = 0, c = 0, d = 0 }
+ effect(function() b(); count.b += 1 end)
+ effect(function() c(); count.c += 1 end)
+ effect(function() d(); count.d += 1 end)
+
+ a(1)
+ CHECK(count.b == 2)
+ CHECK(count.c == 2)
+ CHECK(count.d == 2)
+ CHECK(d() == 2)
+
+ a(3)
+ CHECK(count.b == 2)
+ CHECK(count.c == 3)
+ CHECK(count.d == 3)
+ CHECK(d() == 6)
+ end
+
+ do CASE "diamond A,B,C,D,E"
+ --[[
+
+ a > b > e
+ > c > d >
+
+ ]]
+
+ local a = source(0)
+
+ local b = derive(function() return (a() % 2 == 0) and 1 or 0 end)
+ local c = derive(function() return a() * 2 end)
+ local d = derive(function() return c() * 2 end)
+ local e = derive(function() return b() + d() end)
+
+ local count = { b = 0, c = 0, d = 0, e = 0 }
+ effect(function() b(); count.b += 1 end)
+ effect(function() c(); count.c += 1 end)
+ effect(function() d(); count.d += 1 end)
+ effect(function() e(); count.e += 1 end)
+
+ CHECK(e() == 1)
+
+ a(1)
+
+ CHECK(count.b == 2)
+ CHECK(count.c == 2)
+ CHECK(count.d == 2)
+ CHECK(count.e == 3) -- todo: redundant re-eval
+ CHECK(e() == 4)
+
+ a(3)
+ CHECK(count.b == 2)
+ CHECK(count.c == 3)
+ CHECK(count.d == 3)
+ CHECK(count.e == 4)
+ CHECK(e() == 12)
+ end
+
+ do CASE "repeated read"
+ local a = source(0)
+ local b = derive(function() return a() + a() end)
+
+ local count = 0
+ effect(function() b(); count += 1 end)
+
+ a(1)
+ CHECK(b() == 2)
+ CHECK(count == 2)
+ end
+
+ do CASE "do not destroy children"
+ local parent = source(0)
+
+ local
+ destroy,
+ parent_to_destroy,
+ update_parent_to_destroy
+ = root(function(destroy)
+ local src = source(0)
+ return
+ destroy,
+ derive(function() return src() end),
+ src
+ end)
+
+ local count = 0
+
+ effect(function()
+ count += 1
+ parent()
+ parent_to_destroy()
+ end)
+
+ parent(parent() + 1)
+ CHECK(count == 2)
+ update_parent_to_destroy(1)
+ CHECK(count == 3)
+
+ destroy()
+
+ update_parent_to_destroy(2)
+ CHECK(count == 3)
+
+ parent(parent() + 1)
+ CHECK(count == 4)
+ end
+
+ do CASE "double destroy"
+ -- issue:
+ -- parent evaluates
+ -- child A queued
+ -- child B queued
+ -- child A destroys child B
+ -- child B reevaluates due to already being queued
+ -- parent destroys, destroys child B - uh oh
+
+ local
+ destroy_parent,
+ parent,
+ update_parent
+ = root(function(destroy)
+ local src = source(0)
+ return
+ destroy,
+ derive(function() return src() end),
+ src
+ end)
+
+ local destroy_child, _child_B = function() end, nil
+
+ local count_A = 0
+
+ -- child_A
+ effect(function()
+ count_A += 1
+ parent()
+ destroy_child()
+ end)
+
+ local count_B = 0
+ destroy_child, _child_B = root(function(destroy)
+ return
+ destroy,
+ derive(function() count_B += 1; return parent() end)
+ end)
+
+ update_parent(parent() + 1)
+ CHECK(count_A == 2)
+ CHECK(count_B == 1) -- child B should not run again
+ destroy_parent() -- should not error
+ CHECK(true)
+ end
+end))
+
TEST("strict", wrap_root(function()
vide.strict = true
@@ -1640,6 +2269,22 @@ TEST("strict", wrap_root(function()
CHECK(ok)
end
+
+ do CASE "effect counter"
+ local src = source(true)
+
+ local count = 0
+
+ effect(function(x: number)
+ src()
+ count = x + 1
+ return count
+ end, count)
+
+ CHECK(count == 2)
+ src(not src())
+ CHECK(count == 4)
+ end
end))
local ok = FINISH()
diff --git a/todo.md b/todo.md
index 9fb48b6..6f254bd 100644
--- a/todo.md
+++ b/todo.md
@@ -1,14 +1,4 @@
# todo
-- auto-enable of strict mode depending on compiler optimizaton level
-- property binding optimization
- - would no longer allow `cleanup()` usage in binding scopes
-- solution to nested reactivity, see: SolidJS stores
-- investigate performance of wide graphs
-- optimize child removal
-- implement from solid:
- - Portal
- - batch
-- optimize `indexes()` double-diffing
-- define behavior of deriving a source within a derived source
-- review destruction of node under a root that has a child in another root
+- improve error traces
+- prevent redundant re-eval of nodes in a complex diamond graph
diff --git a/wally.toml b/wally.toml
new file mode 100644
index 0000000..b1387e5
--- /dev/null
+++ b/wally.toml
@@ -0,0 +1,19 @@
+[package]
+name = "centau/vide"
+description = "A reactive Luau library for creating UI. "
+license = "MIT"
+version = "0.2.0"
+registry = "https://github.com/UpliftGames/wally-index"
+realm = "shared"
+include = ["default.project.json", "LICENSE", "src"]
+exclude = [
+ ".github",
+ "docs",
+ "test",
+ ".gitattributes",
+ ".gitignore",
+ ".luaurc",
+ "CHANGELOG.md",
+ "README.md",
+ "todo.md"
+]