Merge branch 'main' into main

This commit is contained in:
richard 2024-08-18 14:51:34 -07:00 committed by GitHub
commit 1806339bac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 543 additions and 895 deletions

1
.gitattributes vendored
View file

@ -1 +0,0 @@
*.luau linguist-language=Lua

View file

@ -14,7 +14,7 @@ jobs:
uses: robinraju/release-downloader@v1.6 uses: robinraju/release-downloader@v1.6
with: with:
repository: Roblox/luau repository: Roblox/luau
latest: true tag: "0.620"
fileName: luau-ubuntu.zip fileName: luau-ubuntu.zip
out-file-path: bin out-file-path: bin

View file

@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
## Unreleased
### Fixed
- Error stack traces being lost.
--------------------------------------------------------------------------------
## [0.2.0] - 2023-11-22 ## [0.2.0] - 2023-11-22
### Added ### Added

View file

@ -43,16 +43,15 @@ export default withMermaid({
{ text: "Components", link: "/tut/crash-course/3-components" }, { text: "Components", link: "/tut/crash-course/3-components" },
{ text: "Sources", link: "/tut/crash-course/4-source" }, { text: "Sources", link: "/tut/crash-course/4-source" },
{ text: "Effects", link: "/tut/crash-course/5-effect" }, { text: "Effects", link: "/tut/crash-course/5-effect" },
{ text: "Root Scopes", link: "/tut/crash-course/6-root" }, { text: "Scopes", link: "/tut/crash-course/6-scope" },
{ text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" },
{ text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" },
{ text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" },
{ text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" },
{ text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, { 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/12-actions" },
{ text: "Actions", link: "/tut/crash-course/13-actions" }, { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" },
{ text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" }, { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" }
{ text: "Concepts Summary", link: "/tut/crash-course/15-concepts" }
] ]
}, },
{ {

View file

@ -18,8 +18,8 @@ Returns a new source with a value always moving torwards the input source value.
- **Details** - **Details**
The output source value is updated every step based on the input source An effect is created to update the new source every frame based on the input
value. source value.
The movement is physically simulated according to a The movement is physically simulated according to a
[spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion).

View file

@ -4,7 +4,7 @@
## mount() ## mount()
Runs a function in a new reactive scope and optionally applies its result to a Runs a function in a new stable scope and optionally applies its result to a
target instance. target instance.
- **Type** - **Type**
@ -18,10 +18,10 @@ target instance.
The result of the function is applied to a target in the same way The result of the function is applied to a target in the same way
properties are using `create()`. properties are using `create()`.
The function is ran in a new reactive scope, just like The function is ran in a new stable scope, just like
[root()](reactivity-core.md#root). [root()](reactivity-core.md#root).
Returns a function that when called will destroy the reactive scope. Returns a function that when called will destroy the stable scope.
- **Example** - **Example**
@ -170,7 +170,7 @@ A wrapper for `action()` to listen for property changes.
Will run the given callback any time the property is changed, as well as Will run the given callback any time the property is changed, as well as
when the action is initially run. when the action is initially run.
The changed connection is disconnected when the reactive scope the action is The changed connection is disconnected when the scope the action is ran in
ran in is destroyed. is destroyed.
Runs with an action priority of 1. Runs with an action priority of 1.

View file

@ -3,12 +3,13 @@
<br/> <br/>
:::warning :::warning
Yielding is not allowed in any reactive scope. Strict mode can check for this. Yielding is not allowed in any stable or reactive scope. Strict mode will check
for this.
::: :::
## root() ## root()
Creates and runs a function in a new reactive scope. Creates and runs a function in a new stable scope.
- **Type** - **Type**
@ -20,8 +21,8 @@ Creates and runs a function in a new reactive scope.
Returns the result of the given function. Returns the result of the given function.
Creates a new root reactive scope, where creation and derivations of sources Creates a new stable scope, where creation of effects can be tracked and
can be tracked and properly disposed of. properly disposed of.
A function to destroy the root is passed into the callback, which will run A function to destroy the root is passed into the callback, which will run
any cleanups and allow derived sources created to garbage collect. any cleanups and allow derived sources created to garbage collect.
@ -41,11 +42,6 @@ Creates a new source with the given value.
Calling the returned source with no argument will return its stored value, Calling the returned source with no argument will return its stored value,
calling with an argument will set a new value. calling with an argument will set a new 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** - **Example**
```lua ```lua
@ -68,10 +64,10 @@ Runs a side-effect in a new reactive scope on source update.
- **Details** - **Details**
Any time a source referenced in the callback is changed, the callback will Any time a source referenced in the callback is updated, the callback will
be reran. be reran.
The callback is ran to initially ran on first call to find dependent sources. The callback is ran once immediately.
- **Example** - **Example**
@ -107,7 +103,7 @@ Derives a new source in a new reactive scope from existing sources.
Anytime its value is recalculated it is also cached, subsequent calls will Anytime its value is recalculated it is also cached, subsequent calls will
retun this cached value until it recalculates again. retun this cached value until it recalculates again.
The callback is ran to initially ran on first call to find dependent sources. The callback is ran once immediately.
- **Example** - **Example**

View file

@ -18,12 +18,12 @@ Shows one of two components depending on an input source.
Returns a source holding an instance of the currently shown component. Returns a source holding an instance of the currently shown component.
When the input source changes from a falsey to a truthy value, the 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 component will be reran under a new stable scope. If it changes from a
truthy to falsey value, the reactive scope the component was created in will truthy to falsey value, the stable scope the component was created in will
be destroyed, and the returned source will output `nil`, or a fallback be destroyed, and the returned source will output `nil`, or a fallback
component if given. component if given.
The fallback component is also ran under a new reactive scope, and destroyed The fallback component is also ran under a new stable scope, and destroyed
when the input source switches back to truthy. when the input source switches back to truthy.
## switch() ## switch()
@ -41,10 +41,10 @@ Shows one of a set of components depending on an input source and a mapping tabl
Returns a source holding an instance of the currently shown component. Returns a source holding an instance of the currently shown component.
When the input source changes, the new value will be used to lookup a given 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 mapping table to get a component, which will be ran under a new stable
scope. If the input source changes, the reactive scope the component was scope. If the input source changes, the stable scope the component was
created in will be destroyed, and a new component created under a new 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 stable scope. If no component is found for an input value, the switch will
output `nil`. output `nil`.
- **Example** - **Example**
@ -82,9 +82,9 @@ Maps each index in a table source to an object.
When the input source changes, each *index* in the new table is compared with When the input source changes, each *index* in the new table is compared with
the last input table. the last input table.
- For any new index, the `transform` function is ran under a new reactive - For any new index, the `transform` function is ran under a new stable
scope to produce a new instance. scope to produce a new instance.
- For any removed index, the reactive scope for that index is destroyed. - For any removed index, the stable scope for that index is destroyed.
- Unchanged indexes are untouched. - Unchanged indexes are untouched.
The transform function is called only ever *once* for each index in the The transform function is called only ever *once* for each index in the
@ -142,9 +142,9 @@ Maps each value in a table source to an object.
When the input source changes, each *value* in the new table is compared with 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. 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 - For any new value, the `transform` function is ran under a new stable
scope to produce a new instance. scope to produce a new instance.
- For any removed value, the reactive scope for that value is destroyed. - For any removed value, the stable scope for that value is destroyed.
- Unchanged values are untouched. - Unchanged values are untouched.
The transform function is only ever called *once* for each value in the The transform function is only ever called *once* for each value in the
@ -203,7 +203,7 @@ Maps each value in a table source to an object.
- Toast notifications. - Toast notifications.
`indexes()` should be used in other cases, especially when your source table `indexes()` should be used in other cases, especially when your source table
has primitive value. It maps an index to a UI element. has primitive values. It maps an index to a UI element.
e.g. e.g.
- List of character or weapon stats. - List of character or weapon stats.
@ -213,6 +213,6 @@ Maps each value in a table source to an object.
result in less property updates and less re-renders. One case to note is 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 that `values()` works nicely when animating re-ordering of instances, since
the value is not destroyed when indexes are changed, and the source index the value is not destroyed when indexes are changed, and the source index
can easily be put through a spring. can be used to animate a change in position for the UI element.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
## cleanup() ## cleanup()
Runs a callback anytime a reactive scope is reran or destroyed. Runs a callback anytime a scope is reran or destroyed.
- **Type** - **Type**
@ -31,8 +31,7 @@ Runs a callback anytime a reactive scope is reran or destroyed.
## untrack() ## untrack()
Runs a given function where any sources read will not be tracked by a reactive Runs a given function in a new stable scope.
scope.
- **Type** - **Type**
@ -42,8 +41,8 @@ scope.
- **Details** - **Details**
Updates made to a source passed to `untrack()` will not cause updates to Can be used inside a reactive scope to read from sources you do not want
anything depending on that source. tracked by the reactive scope.
- **Example** - **Example**
@ -76,7 +75,7 @@ read can still be tracked inside a reactive scope.
## batch() ## batch()
Runs a given function where any source updates made within the function do not Runs a given function where any source updates made within the function do not
trigger effects until after the function runs. trigger effects until after the function finishes running.
- **Type** - **Type**

View file

@ -22,12 +22,12 @@ Currently, strict mode will:
6. Checks for duplicate nested properties at same depth. 6. Checks for duplicate nested properties at same depth.
7. Better error reporting and stack traces + creation traces of property bindings. 7. Better error reporting and stack traces + creation traces of property bindings.
By rerunning derived sources and effects twice each time they update,it helps By rerunning derived sources and effects twice each time they update, it helps
ensure that derived source computations are pure, and that any ensure that derived source computations are pure, and that any
cleanups made in derived sources or effects are done correctly. cleanups made in derived sources or effects are done correctly.
Accidental yielding within reactive scopes can break Vide's reactive graph, Accidental yielding within reactive scopes can break Vide's reactive graph,
which strict mode can catch. which strict mode will catch.
As well as additional safety checks, Vide will dedicate extra resources to As well as additional safety checks, Vide will dedicate extra resources to
recording and better emitting stack traces where errors occur, particularly recording and better emitting stack traces where errors occur, particularly

View file

@ -1,7 +1,6 @@
# Nested Reactive Scopes # Nested Scopes
Nesting reactive scopes gives you finer control over the reactive graph, but Nesting 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
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. most common cases, but they do not cover all of them.
This tutorial will demonstrate how to implement a `show()` control flow function This tutorial will demonstrate how to implement a `show()` control flow function
@ -21,7 +20,7 @@ local function Counter()
} }
end end
mount(function() root(function()
local toggled = source(true) local toggled = source(true)
show(toggled, Button) show(toggled, Button)
@ -57,7 +56,7 @@ 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 `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 `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 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 scope rerunning will destroy any scopes created within it. So the text
effect's reactive scope is destroyed whenever the show effect is rerun. effect's reactive scope is destroyed whenever the show effect is rerun.
The same can be achieved without the use of `show()`: The same can be achieved without the use of `show()`:
@ -82,7 +81,10 @@ mount(function()
effect(function() effect(function()
if toggled() then if toggled() then
local destroy = mount(Button) local destroy = root(function(destroy)
Counter()
return destroy
end)
cleanup(destroy) cleanup(destroy)
end end
end) end)
@ -116,11 +118,14 @@ subgraph mount
end end
``` ```
This is another way to achieve the same. Here we use `mount()` within the effect This is another way to achieve the same. Here we use `root()` within the effect
to manually create and destroy a new reactive scope whenever the effect reruns. to manually create and destroy a new stable scope whenever the effect reruns.
Alternatively, instead of using `mount()`, a new reactive scope can be created The reason for creating a stable scope is to prevent the effect from tracking
directly within the effect: any sources that may be read inside the `Counter()` call. Otherwise, the effect
may be rerun needlessly and recreate the counter.
Alternatively, instead of using `root()`:
```lua ```lua
local mount = vide.mount local mount = vide.mount
@ -174,7 +179,9 @@ end
``` ```
Without the use of `untrack()`, an error would occur, since Vide does not allow 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 the creation of reactive scopes inside reactive scopes. `untrack()` creates a
stable scope inside the reactive scope, and we can create another reactive scope
inside that stable scope. The
reason for this, is because if the `Counter` component reads from a source 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 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 source, causing unintentional reruns. As a guard against this, you are forced to
@ -184,5 +191,3 @@ 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 created which creates the counter, which creates its own reactive scope. The
effect rerunning causes the counter's internal reactive scope to be destroyed, effect rerunning causes the counter's internal reactive scope to be destroyed,
making sure everything is cleaned up. making sure everything is cleaned up.

View file

@ -4,22 +4,12 @@ This is a tutorial that introduces the concepts and usage of Vide.
Vide is heavily inspired by [Solid](https://www.solidjs.com/). Vide is heavily inspired by [Solid](https://www.solidjs.com/).
This tutorial assumes familiarity with Luau and Roblox UI.
## Why Vide? ## Why Vide?
Creating UI is complicated, slow, and tedious. Vide provides a reactive and declarative API to simplify managing UI.
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: Some of the main focuses behind Vide's design choices:
- Minimal syntax. - Minimal syntax
- Complete typechecking - Complete typechecking
- Independence from instances. - Independence from instances
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.

View file

@ -2,44 +2,33 @@
Sometimes you may need to do some cleanup when destroying a component or after 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 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 is used to queue a callback for the next time a reactive scope is rerun or
or destroyed. destroyed, or when a stable scope is destroyed.
```lua ```lua
local mount = vide.mount local root = vide.root
local source = vide.source local source = vide.source
local cleanup = vide.cleanup local effect = vide.effect
local function Timer() local count = source(0)
local count = source(0)
local con = game:GetService("RunService").Heartbeat:Connect(function(dt)
count(count() + dt) local destroy = root(function(destroy)
effect(function()
local x = count()
cleanup(function() print(x) end)
end) end)
cleanup(function() cleanup(function() print "root destroyed" end)
con:Disconnect()
end)
return create "TextButton" { return destroy
Position = UDim2.fromOffset(300, 300), end)
Size = UDim2.fromOffset(200, 50),
Text = function() count(1) -- prints "0"
return "seconds: " .. math.floor(count()) count(2) -- prints "1"
end, destroy() -- prints "2" and "root destroyed"
}
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 ::: tip
Roblox instances do not need to be explicitly destroyed for their 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 memory to be freed, they only need to be parented to `nil`. So there is no

View file

@ -5,119 +5,26 @@ resulting from source updates. Vide provides functions to help you do this,
known as *control flow* functions. known as *control flow* functions.
These functions return new sources, which hold the instances to be displayed. These functions return new sources, which hold the instances to be displayed.
The new sources can be used in `create()` to update the children of a container
Control flow functions run their components in a new reactive scope, which can instance.
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() ## indexes()
Often, you will have a table of values with each value displayed in a similar `indexes()` *maps* each table index to a new UI element that can
manner. Rather than manually looping over each value to generate a corresponding update to display the current value at that index. Each table index is given a
UI element, `indexes()` allows you to create elements each corresponding to a single corresponding UI element.
table index, to display the value at that index.
```lua ```lua
local todoList = source { local list = source {
"finish the crash course", "finish the crash course",
"star vide's GitHub" "star Vide's GitHub"
} }
local function TodoList(props: { list: () -> Array<string> }) local function TodoList(props: { list: () -> Array<string> })
return create "Frame" { return create "Frame" {
create "UIListLayout" {}, create "UIListLayout" {},
indexes(todoList, function(todo, i) indexes(list, function(todo, i)
return create "TextLabel" { return create "TextLabel" {
Text = function() Text = function()
return i .. ": " .. todo() return i .. ": " .. todo()
@ -129,13 +36,13 @@ local function TodoList(props: { list: () -> Array<string> })
} }
end end
TodoList { list = todoList } TodoList { list = list }
``` ```
For each index in the given source table, the given function will be called For each index in the given source table, the given function to `indexes()` will
with: be run in a new stable scope with:
1. a source containing the value of the index 1. a source containing the value at the index
2. the index itself 2. the index itself
When the value at an index is changed, the function is not reran. Instead, the When the value at an index is changed, the function is not reran. Instead, the
@ -143,12 +50,9 @@ given source for that index is updated.
Any time the input source table is updated, the given function will be ran for 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` 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 value), will have its corresponding stable scope destroyed.
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: The reactive graph for the above example:
@ -183,8 +87,8 @@ subgraph root ["root scope"]
end end
``` ```
One thing to note regarding table sources, is that when you edit a table in a When you edit a table in a source, you must set that table again to actually
source, you must set that table again to actually update the source. update the source.
```lua ```lua
local src = source { 1, 2 } local src = source { 1, 2 }
@ -192,11 +96,3 @@ local data = src()
table.insert(data, 3) -- no effects will run table.insert(data, 3) -- no effects will run
src(data) -- 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.

View file

@ -24,6 +24,7 @@ action used to listen for property changes:
```lua ```lua
local action = vide.action local action = vide.action
local effect = vide.effect
local cleanup = vide.cleanup local cleanup = vide.cleanup
local function changed(prop: string, callback: (new) -> ()) local function changed(prop: string, callback: (new) -> ())
@ -44,9 +45,11 @@ local instance = create "TextBox" {
changed("Text", output) changed("Text", output)
} }
instance.Text = "foo" effect(function()
print(output())
end)
print(output()) -- "foo" instance.Text = "foo" -- "foo" will be printed from the effect
``` ```
The source `output` will be updated with the new property value any time it is The source `output` will be updated with the new property value any time it is

View file

@ -1,120 +0,0 @@
# Nested Properties
Often when creating components from existing components, you can find yourself
repetitively passing through properties such as size or position.
Example below:
```lua
function Background(props: {
Color: Color3,
AnchorPoint: UDim2,
Position: UDim2,
Size: UDim2
})
return create "Frame" {
Color = props.Color
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
}
end
function Menu(props: {
Color = props.Color
AnchorPoint: UDim2,
Position: UDim2,
Size: UDim2
})
return Background {
Color = props.Color,
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
}
end
```
One way this can be avoided is by using *property nesting*. In Vide, passing a
table value inside `props` has special semantics. Any key with a table value is
not assigned like a property, instead the table is iterated and processed just
like the outer table is. Any properties in the nested table will be assigned
to the instance just the same.
Below is an example of how you can use this to pass groups of similar properties
together such as position and size, while also using typechecking.
```lua
type Layout = {
Layout = {
Position: UDim2?,
Size: UDim2?,
AnchorPoint: Vector2?
}
}
function Background(props: Layout & { Color: Color3 })
return create "Frame" {
Color = props.Color,
props.Layout
}
end
function Menu(props: Layout & { Color: Color3 })
return Background {
Color = props.Color,
Layout = props.Layout
}
end
```
Here we created a nested group with the key `Layout` that can accept
layout-related properties. Any name could be chosen for the key.
This allows us to write much more concise syntax that is also typecheckable.
In another example we use a key named `Children` to pass arrays of instances to
be parented.
```lua
type Children = {
-- also can optionally pass a source that returns an array of children too
Children = Array<Instance> | () -> Array<Instance>
}
local function List(props: Children & Layout)
return create "Frame" {
props.Children,
props.Layout,
create "UIListLayout" {}
}
end
List {
Layout = {
Position = UDim2.new()
},
Children = {
create "TextLabel" { Text = "1" },
create "TextLabel" { Text = "2" }
}
}
```
Deeper nested properties are guaranteed to be set after shallower nested
properties, this can be used to create overridable default properties.
```lua
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),
create "UIListLayout" {}
}
end
```

View file

@ -11,7 +11,7 @@ want this.
Strict mode will run derived sources and effects twice each time they update. 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 This is to help ensure that derived source computations are pure, and that any
cleanups made in derived sources or effects are done correctly. cleanups made in derived sources or effects are done properly.
```lua ```lua
local source = vide.source local source = vide.source

View file

@ -22,52 +22,52 @@ Anything that happens in response to a source update.
Created with `effect()`. Created with `effect()`.
## Reactive Scope ## Stable Scope
A scope created by certain functions such as: One of the two types of Vide scopes.
Created by:
- `root()` - `root()`
- `untrack()`
- `switch()`
- `indexes()`
Stable scopes do not track sources and never rerun.
New stable or reactive scopes can be created within a stable scope.
## Reactive Scope
Created by:
- `effect()` - `effect()`
- `derive()` - `derive()`
Reactive scopes can: Reactive scopes do track sources and will rerun when those sources update.
- track sources that are read from within. New reactive scopes cannot be created within a reactive scope, but stable scopes
- rerun when a tracked source updates. can.
- track new reactive scopes created from within.
## Scope Owners ## Scope Owners
A reactive scope created within another reactive scope is *owned* by the other A scope created within another scope is *owned* by the other scope, with the
reactive scope, with the exception of the reactive scope created by `root()`. exception of the scope created by `root()`.
When a reactive scope is rerun or destroyed, all reactive scopes owned by it are When a scope is rerun or destroyed, all scopes owned by it are automatically
automatically destroyed. destroyed.
`root()`, which `mount()` uses internally, creates a reactive scope with no `root()` creates a stable scope with no owner, instead it is destroyed manually.
owner, since it must be destroyed manually using a destructor
returned.
## Cleanup ## Cleanup
Arbitrary code to run whenever a reactive scope is rerun or destroyed. Arbitrary code to run whenever a stable or reactive scope is rerun or destroyed.
Queue a function to run using `cleanup()`. 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 ## Reactive Graph
The combination of reactive scopes can viewed graphically, called a The combination of stable and reactive scopes can viewed graphically, called a
*reactive graph*. This can be a more intuitive way to think of the *reactive graph*. This can be a more intuitive way to think of the
relationships between effects and the sources they depend on. relationships between effects and the sources they depend on.
@ -114,10 +114,10 @@ count --> text
Notes: Notes:
- Since `count` is a source, not an effect, it can exist - Since `count` is a source, not an effect, it can exist
outside of a root reactive scope. outside of scopes.
- An update to `count` will cause `text` to rerun, which - An update to `count` will cause `text` to rerun, which
then causes `effect` to rerun. then causes `effect` to rerun.
- When the root reactive scope is destroyed, `text` and - When the root scope is destroyed, `text` and
`effect` will be destroyed alongside it, since they are `effect` will be destroyed alongside it, since they are
owned by it. `count` will be untouched and future updates owned by it. `count` will be untouched and future updates
to `count` will have no effect. to `count` will have no effect.

View file

@ -2,11 +2,8 @@
Instances are created using `create()`. Instances are created using `create()`.
`create()` returns a constructor for a class which then takes a table of Parentheses `()` can be omitted when calling functions with string or
properties to assign when creating a new instance for that class. table literals for brevity.
Luau allows us to omit parentheses `()` when calling functions with string or
table literals which is recommended to use for brevity.
```lua ```lua
local create = vide.create local create = vide.create
@ -39,10 +36,3 @@ return create "ScreenGui" {
Assign a value to a string key to set a property, and assign a value to a 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 number key to set a child. Events can be connected to by assigning a function
to a string key. to a string key.
::: 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.
:::

View file

@ -34,12 +34,12 @@ end
return Button return Button
``` ```
```lua [App.luau] ```lua [Menu.luau]
local create = vide.create local create = vide.create
local Button = require(Button) local Button = require(Button)
local function App() local function Menu()
return create "ScreenGui" { return create "ScreenGui" {
Button { Button {
Position = UDim2.fromOffset(200, 200), Position = UDim2.fromOffset(200, 200),
@ -58,20 +58,8 @@ local function App()
} }
} }
end end
App().Parent = game.StarterGui
``` ```
::: :::
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. A single parameter `props` is used to pass properties to the component.
You can only modify the component in ways that you allow in the component,
through the `props` parameter.
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.

View file

@ -1,7 +1,7 @@
# Sources # Sources
Sources are special objects that store a single value. They are the core of Sources are special objects that store a single value and are the core of
Vide's reactivity. They are called sources because they act as sources of data. Vide's reactivity.
A source can be created using `source()`. A source can be created using `source()`.
@ -20,8 +20,7 @@ by calling it with no arguments.
count(count() + 1) -- increment count by 1 count(count() + 1) -- increment count by 1
``` ```
Sources can be *derived* by wrapping them in functions. A wrapped source Sources can be *derived* by wrapping them in functions.
effectively becomes a new source.
```lua ```lua
local count = source(0) local count = source(0)
@ -35,7 +34,5 @@ count(1)
print(text()) -- "count: 1" print(text()) -- "count: 1"
``` ```
Sources on their own aren't very special, the above can be achieved with plain While the above can be achieved with plain variables, the use for sources will
variables. The real use for sources become apparent when used in combination be obvious in the next part.
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.

View file

@ -1,8 +1,7 @@
# Effects # Effects
Effects are functions that are ran in response to source updates. They are Effects are functions that are ran in response to source updates. They are
called effects because they cause *side-effects* when reacting to source A source and effect is analogous to a signal and connection.
updates.
Effects are created using `effect()`. Effects are created using `effect()`.
@ -21,11 +20,10 @@ count(1)
-- "count: 1" printed -- "count: 1" printed
``` ```
The callback given to `effect()` is ran immediately in a *reactive scope*. Any Any source read inside an effect is tracked and will rerun the effect when
source read from inside a reactive scope will be tracked, so when any of those that source is updated.
sources update, the effect will be reran too.
Reactive scopes also track derived sources, it doesn't matter how deeply nested Derived sources are also tracked, it doesn't matter how deeply nested
inside a function a source is. inside a function a source is.
```lua ```lua

View file

@ -1,80 +0,0 @@
# 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.

View file

@ -0,0 +1,105 @@
# Scopes
Just like how a signal's connection may need to be disconnected, a source's
effect also may need to be disconnected.
But the disconnecting of many signals and connections is tedious and verbose.
Vide instead operates on the concept of scopes which provides a much cleaner
API, given that you follow a few rules.
Scopes come in two flavors; stable and reactive.
- All scopes must be created within another scope with the exception of `root()`
- Stable scopes never rerun
- Reactive scopes can rerun
- A reactive scope cannot be created within another reactive scope
`effect()` creates a reactive scope.
`root()` creates a stable scope.
Whenever a scope is destroyed, any scope created within that scope is also
destroyed, and so on. This is why all scopes must be created within another
scope, except `root()` which is used to create the initial scope that you can
manually destroy.
```lua
local root = vide.root
local source = vide.source
local effect = vide.effect
local function setup()
local count = source(0)
effect(function()
print(count())
end)
return count
end
setup() -- will error since effect() tries to create a reactive scope outside of a stable scope
local count = root(setup) -- ok since effect() was called within a stable scope
count(1) -- prints "1"
```
The scope created by `root()` can be destroyed by calling the function it passes
into the given function.
```lua
local function setup(destroy)
local count = source(0)
effect(function()
print(count())
end)
return count, destroy
end
local count, destroy = root(setup)
count(1) -- prints "1"
destroy()
count(2) -- effect is destroyed; no longer prints
```
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 stable `root()` is destroyed, the reactive `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
stable scope, since you should be creating all your UI and effects within a
single top-level `root()` call that puts all your UI together, making it safe to
assume any effect created will be created under this stable scope.

View file

@ -1,7 +1,5 @@
# Stateful Components # 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 Stateful components in Vide are created using sources and effects - sources to
store the data, and effects to display the data. store the data, and effects to display the data.
@ -27,8 +25,6 @@ local function Counter()
return instance return instance
end end
mount(Counter, game.StarterGui)
``` ```
Above is an example of a counter component, that when clicked, will increment Above is an example of a counter component, that when clicked, will increment
@ -37,9 +33,6 @@ its internal count, and automatically update its text to reflect that count.
Each instance of `Counter()` will maintain its own independent count, since the Each instance of `Counter()` will maintain its own independent count, since the
count source is created inside the component. 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 State
External sources can also be passed into components for them to use. External sources can also be passed into components for them to use.
@ -72,4 +65,4 @@ count(1) -- the Counter component will update to display this count
Sources can be created internally or passed in from externally, there are no 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 restrictions on how they are used as long as the effect using it is created
within a reactive scope. within a stable scope.

View file

@ -1,7 +1,7 @@
# Property Binding # Implicit Effects
Explicitly creating effects to update properties can be tedious. Vide provides a Explicitly creating effects to update properties is tedious. You can
way to *implicitly* create an effect to update properties. *implicitly* create an effect to update properties instead.
```lua ```lua
local create = vide.create local create = vide.create
@ -25,23 +25,14 @@ end
This example is equivalent to the example seen on the previous page. This example is equivalent to the example seen on the previous page.
Instead of explicitly creating an effect, assigning a (non-event) property a Instead of explicitly creating an effect, assigning a (non-event) property a
function will implicitly create an effect to update that property anytime a function will implicitly create an effect to update that property.
source used within is updated.
Just like effects, the function is ran immediately in a reactive scope to set ## Children
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 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 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. An effect is automatically created to unparent removed instances and
instances when that source's stored instances change. parent new instances on source update.
```lua ```lua
local items = source { local items = source {
@ -62,5 +53,5 @@ items {
create "TextLabel" { Text = "C" } create "TextLabel" { Text = "C" }
} }
-- this will automatically unparent the text label "A", and parent the labels "B" and "C". -- this will automatically unparent the text label "A", and parent the labels "B" and "C"
``` ```

View file

@ -36,14 +36,13 @@ source(1) -- prints "ran" x2
``` ```
To avoid this, you can use `derive()` to derive a new source instead. This will 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. run a function in a reactive scope only when a source used inside updated.
Reading this derived source multiple times will just return a cached result from Reading this derived source multiple times will just return a cached result.
when it last updated.
```lua ```lua
local source = vide.source local source = vide.source
local derive = vide.derive
local effect = vide.effect local effect = vide.effect
local derive = vide.derive
local count = source(0) local count = source(0)
@ -58,7 +57,7 @@ effect(function() text() end)
source(1) -- prints "ran" x1 source(1) -- prints "ran" x1
``` ```
`derive()` must also be called within a reactive scope, just like `effect()`. `derive()` must also be called within a stable scope, just like `effect()`.
If the recalculated value is the same as the old value, the derived source will If the recalculated value is the same as the old value, the derived source will
not rerun the effects using it. not rerun the effects using it.
@ -87,6 +86,7 @@ end
``` ```
Deriving a source in this manner is similar to creating an effect to update 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, another source. You should never manually do this using an effect however.
improper usage could accidently create infinite loops in the reactive graph. Improper usage could accidently create infinite loops in the reactive graph.
Always favour deriving when you need one source to update based on another. Always favour deriving when you need one source to update based on another
source.

View file

@ -1,42 +1,13 @@
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
local trace = require(script.Parent.trace)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_node = graph.create_node local create_node = graph.create_node
local assert_owning_scope = graph.assert_owning_scope local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
function create_binding<T>(updater: (T) -> T, binding: T) function create_implicit_effect<T>(updater: (T) -> T, binding: T)
if flags.strict then evaluate_node(create_node(assert_stable_scope(), updater, binding))
-- track bind creation trace
local fn = updater
local bind_trace = debug.traceback(nil, trace()-1)
updater = function(...)
local ok, result = xpcall(fn, function(err: string)
return err
end, ...)
if not ok then
local btype =
if (binding :: any).property then (binding :: any).property
elseif (binding :: any).parent then "Parent"
else "children"
error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0)
end
return result
end
end
local owner = assert_owning_scope()
local node = create_node(binding, updater)
set_owner(node, owner)
evaluate_node(node)
end end
type PropertyBinding = { type PropertyBinding = {
@ -45,7 +16,7 @@ type PropertyBinding = {
source: () -> unknown source: () -> unknown
} }
local function update_property(p: PropertyBinding) local function update_property_effect(p: PropertyBinding)
(p.instance :: any)[p.property] = p.source() (p.instance :: any)[p.property] = p.source()
return p return p
end end
@ -55,7 +26,7 @@ type ParentBinding = {
parent: () -> Instance parent: () -> Instance
} }
local function update_parent(p: ParentBinding) local function update_parent_effect(p: ParentBinding)
p.instance.Parent = p.parent() p.instance.Parent = p.parent()
return p return p
end end
@ -67,7 +38,7 @@ type ChildrenBinding = {
children: () -> Instance | { Instance } children: () -> Instance | { Instance }
} }
local function update_children(p: ChildrenBinding) local function update_children_effect(p: ChildrenBinding)
local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update
local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update
@ -100,7 +71,7 @@ end
return { return {
property = function(instance, property, source) property = function(instance, property, source)
return create_binding(update_property, { return create_implicit_effect(update_property_effect, {
instance = instance, instance = instance,
property = property, property = property,
source = source source = source
@ -108,14 +79,14 @@ return {
end, end,
parent = function(instance, parent) parent = function(instance, parent)
return create_binding(update_parent, { return create_implicit_effect(update_parent_effect, {
instance = instance, instance = instance,
parent = parent parent = parent
}) })
end, end,
children = function(instance, children) children = function(instance, children)
return create_binding(update_children, { return create_implicit_effect(update_children_effect, {
instance = instance, instance = instance,
cur_children_set = {}, cur_children_set = {},
new_children_set = {}, new_children_set = {},

View file

@ -4,7 +4,7 @@ local typeof = game and typeof or require "test/mock".typeof :: never
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local get_scope = graph.get_scope local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup local push_cleanup = graph.push_cleanup
local function helper(obj: any) local function helper(obj: any)
return return
@ -21,13 +21,13 @@ local function cleanup(value: unknown)
local scope = get_scope() local scope = get_scope()
if not scope then if not scope then
throw "cannot cleanup in a non-reactive scope" throw "cannot cleanup outside a stable or reactive scope"
end; assert(scope) end; assert(scope)
if type(value) == "function" then if type(value) == "function" then
add_cleanup(scope, value :: () -> ()) push_cleanup(scope, value :: () -> ())
else else
add_cleanup(scope, helper(value)) push_cleanup(scope, helper(value))
end end
end end

View file

@ -2,21 +2,17 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local set_owner = graph.set_owner local push_child_to_scope = graph.push_child_to_scope
local track = graph.track local assert_stable_scope = graph.assert_stable_scope
local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local function derive<T>(source: () -> T): () -> T local function derive<T>(source: () -> T): () -> T
local owner = assert_owning_scope() local node = create_node(assert_stable_scope(), source, false :: any)
local node = create_node(false :: any, source)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
return function() return function()
track(node) push_child_to_scope(node)
return node.cache return node.cache
end end
end end

View file

@ -2,16 +2,12 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local assert_owning_scope = graph.assert_owning_scope local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local function effect<T>(callback: (T) -> T, initial_value: T) local function effect<T>(callback: (T) -> T, initial_value: T)
local owner = assert_owning_scope() local node = create_node(assert_stable_scope(), callback, initial_value)
local node = create_node(initial_value, callback)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
end end

View file

@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags) local flags = require(script.Parent.flags)
export type StartNode<T> = { export type SourceNode<T> = {
cache: T, cache: T,
[number]: Node<T> [number]: Node<T>
} }
@ -16,21 +16,21 @@ export type Node<T> = {
owned: { Node<T> } | false, owned: { Node<T> } | false,
owner: Node<T> | false, owner: Node<T> | false,
parents: { StartNode<T> }, parents: { SourceNode<T> },
[number]: Node<T> -- children [number]: Node<T> -- children
} }
-- reactive scope stack local scopes = { n = 0 } :: { [number]: Node<any>, n: number } -- scopes stack
local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U) local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U)
local thread = coroutine.create(pcall) local thread = coroutine.create(xpcall)
local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) local function efn(err: string) return debug.traceback(err, 3) end
local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg)
assert(resume_ok) assert(resume_ok)
if coroutine.status(thread) ~= "dead" then if coroutine.status(thread) ~= "dead" then
return false, "attempt to yield in reactive scope" return false, debug.traceback(thread, "attempt to yield in reactive scope")
end end
return run_ok, result return run_ok, result
@ -40,46 +40,37 @@ local function get_scope(): Node<unknown>?
return scopes[scopes.n] return scopes[scopes.n]
end end
local function assert_owning_scope(): Node<unknown> local function assert_stable_scope(): Node<unknown>
local scope = get_scope() local scope = get_scope()
if not scope then if not scope then
local caller_name = debug.info(2, "n") local caller_name = debug.info(2, "n")
return throw(`cannot use {caller_name}() in a non-reactive scope`) return throw(`cannot use {caller_name}() outside a stable or reactive scope`)
elseif scope.effect then elseif scope.effect then
throw("cannot create new reactive scope in a tracking reactive scope") throw("cannot create a new reactive scope inside another reactive scope")
end end
return scope return scope
end end
local function add_child<T>(parent: StartNode<any>, child: Node<any>) local function push_child<T>(parent: SourceNode<any>, child: Node<any>)
table.insert(parent, child) table.insert(parent, child)
table.insert(child.parents, parent) table.insert(child.parents, parent)
end end
local function set_owner(node: Node<any>, owner: Node<any>) local function push_scope<T>(node: Node<T>)
node.owner = owner
if owner.owned then
table.insert(owner.owned, node)
else
owner.owned = { node }
end
end
local function open_scope<T>(node: Node<T>)
local n = scopes.n + 1 local n = scopes.n + 1
scopes.n = n scopes.n = n
scopes[n] = node scopes[n] = node
end end
local function close_scope() local function pop_scope()
local n = scopes.n local n = scopes.n
scopes.n = n - 1 scopes.n = n - 1
scopes[n] = nil scopes[n] = nil
end end
local function add_cleanup<T>(node: Node<T>, cleanup: () -> ()) local function push_cleanup<T>(node: Node<T>, cleanup: () -> ())
if node.cleanups then if node.cleanups then
table.insert(node.cleanups, cleanup) table.insert(node.cleanups, cleanup)
else else
@ -87,34 +78,35 @@ local function add_cleanup<T>(node: Node<T>, cleanup: () -> ())
end end
end end
local function run_cleanups<T>(node: Node<T>) local function flush_cleanups<T>(node: Node<T>)
if node.cleanups then if node.cleanups then
for _, fn in next, node.cleanups do for _, fn in next, node.cleanups do
local ok, err: string? = pcall(fn) local ok, err: string? = pcall(fn)
if not ok then throw(`cleanup error: {err}`) end if not ok then throw(`cleanup error: {err}`) end
end end
table.clear(node.cleanups) table.clear(node.cleanups)
end end
end end
local function find_and_swap_pop<T>(t: { T }, v: T) local function find_and_swap_pop<T>(t: { T }, v: T)
local idx = table.find(t, v) :: number local i = table.find(t, v) :: number
local n = #t local n = #t
t[idx] = t[n] t[i] = t[n]
t[n] = nil t[n] = nil
end end
local function unparent<T>(node: Node<T>) local function unparent<T>(node: Node<T>)
local parents = node.parents local parents = node.parents
for i, parent in next, parents do for i, parent in parents do
find_and_swap_pop(parent, node) find_and_swap_pop(parent, node)
parents[i] = nil parents[i] = nil
end end
end end
local function destroy<T>(node: Node<T>) local function destroy<T>(node: Node<T>)
run_cleanups(node) flush_cleanups(node)
unparent(node) unparent(node)
if node.owner then if node.owner then
@ -138,44 +130,51 @@ end
local update_queue = { n = 0 } :: { n: number, [number]: Node<any> } local update_queue = { n = 0 } :: { n: number, [number]: Node<any> }
local function evaluate_node<T>(node: Node<T>) local function evaluate_node<T>(node: Node<T>)
local cur_value = node.cache
if flags.strict then if flags.strict then
run_cleanups(node) local initial_value = node.cache
for i = 1, 2 do
local cur_value = node.cache
flush_cleanups(node)
destroy_owned(node)
push_scope(node)
local ok, new_value = ycall(node.effect :: (T) -> T, cur_value)
pop_scope()
if not ok then
table.clear(update_queue)
update_queue.n = 0
throw(`effect stacktrace:\n{new_value :: string}`)
end
node.cache = new_value :: T
end
return initial_value ~= node.cache
else
local cur_value = node.cache
flush_cleanups(node)
destroy_owned(node) destroy_owned(node)
open_scope(node) push_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, node.cache)
pop_scope()
local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) if not ok then
table.clear(update_queue)
close_scope() update_queue.n = 0
throw(`effect stacktrace:\n{new_value}\n`)
if not ok then throw(new_value :: string) end end
node.cache = new_value :: T node.cache = new_value
return cur_value ~= new_value
end end
run_cleanups(node)
destroy_owned(node)
open_scope(node)
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
end end
local function queue_children<T>(node: StartNode<T>) local function queue_children_for_update<T>(node: SourceNode<T>)
local i = update_queue.n local i = update_queue.n
while node[1] do while node[1] do
i += 1 i += 1
@ -198,7 +197,7 @@ local function flush_update_queue()
--assert(node.effect) --assert(node.effect)
if node.owner and evaluate_node(node) then if node.owner and evaluate_node(node) then
queue_children(node) queue_children_for_update(node)
end end
update_queue[i] = false :: any update_queue[i] = false :: any
@ -210,9 +209,9 @@ local function flush_update_queue()
_flushing = false _flushing = false
end end
local function update<T>(root: StartNode<T>) local function update_descendants<T>(root: SourceNode<T>)
local n0 = update_queue.n local n0 = update_queue.n
queue_children(root) queue_children_for_update(root)
if flags.batch then return end if flags.batch then return end
@ -223,7 +222,7 @@ local function update<T>(root: StartNode<T>)
-- check if node is still owned in case destroyed after queued -- check if node is still owned in case destroyed after queued
if node.owner and evaluate_node(node) then if node.owner and evaluate_node(node) then
queue_children(node) queue_children_for_update(node)
end end
update_queue[i] = false :: any -- false instead of nil to avoid sparse update_queue[i] = false :: any -- false instead of nil to avoid sparse
@ -233,27 +232,37 @@ local function update<T>(root: StartNode<T>)
update_queue.n = n0 update_queue.n = n0
end end
local function track<T>(node: StartNode<T>) local function push_child_to_scope<T>(node: SourceNode<T>)
local scope = get_scope() local scope = get_scope()
if scope and scope.effect then -- do not track nodes with no effect if scope and scope.effect then -- do not track nodes with no effect
add_child(node, scope) push_child(node, scope)
end end
end end
local function create_node<T>(value: T, effect: false | (T) -> T): Node<T> local function create_node<T>(owner: false | Node<any>, effect: false | (T) -> T, value: T): Node<T>
return { local node: Node<T> = {
cache = value, cache = value,
effect = effect, effect = effect,
cleanups = false, cleanups = false,
owner = false, owner = owner,
owned = false, owned = false,
parents = {}, parents = {},
} }
if owner then
if owner.owned then
table.insert(owner.owned, node)
else
owner.owned = { node }
end
end
return node
end end
local function create_start_node<T>(value: T): StartNode<T> local function create_source_node<T>(value: T): SourceNode<T>
return { cache = value } return { cache = value }
end end
@ -262,20 +271,19 @@ local function get_children<T>(node: Node<T>): { Node<unknown> }
end end
return table.freeze { return table.freeze {
open_scope = open_scope, push_scope = push_scope,
close_scope = close_scope, pop_scope = pop_scope,
evaluate_node = evaluate_node, evaluate_node = evaluate_node,
get_scope = get_scope, get_scope = get_scope,
assert_owning_scope = assert_owning_scope, assert_stable_scope = assert_stable_scope,
add_cleanup = add_cleanup, push_cleanup = push_cleanup,
set_owner = set_owner,
destroy = destroy, destroy = destroy,
run_cleanups = run_cleanups, flush_cleanups = flush_cleanups,
track = track, push_child_to_scope = push_child_to_scope,
update = update, update_descendants = update_descendants,
add_child = add_child, push_child = push_child,
create_node = create_node, create_node = create_node,
create_start_node = create_start_node, create_source_node = create_source_node,
get_children = get_children, get_children = get_children,
flush_update_queue = flush_update_queue, flush_update_queue = flush_update_queue,
scopes = scopes scopes = scopes

View file

@ -4,15 +4,14 @@ local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags) local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T> type SourceNode<T> = graph.SourceNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node local create_source_node = graph.create_source_node
local set_owner = graph.set_owner local push_child_to_scope = graph.push_child_to_scope
local track = graph.track local update_descendants = graph.update_descendants
local update = graph.update local assert_stable_scope = graph.assert_stable_scope
local assert_owning_scope = graph.assert_owning_scope local push_scope = graph.push_scope
local open_scope = graph.open_scope local pop_scope = graph.pop_scope
local close_scope = graph.close_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local destroy = graph.destroy local destroy = graph.destroy
@ -22,20 +21,18 @@ local function check_primitives(t: {})
if not flags.strict then return end if not flags.strict then return end
for _, v in next, t do for _, v in next, t do
if type(v) == "table" or type(v) == "userdata" then continue end if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end
throw("table source map cannot return primitives") throw("table source map cannot return primitives")
end end
end end
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO } local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = assert_owning_scope() local owner = assert_stable_scope()
local subowner = create_node(owner, false, false)
local subowner = create_node(false, false)
set_owner(subowner, owner)
local input_cache = {} :: Map<K, VI> local input_cache = {} :: Map<K, VI>
local output_cache = {} :: Map<K, VO> local output_cache = {} :: Map<K, VO>
local input_nodes = {} :: Map<K, StartNode<VI>> local input_nodes = {} :: Map<K, SourceNode<VI>>
local remove_queue = {} :: { K } local remove_queue = {} :: { K }
local scopes = {} :: Map<K, Node<unknown>> local scopes = {} :: Map<K, Node<unknown>>
@ -59,7 +56,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
table.clear(remove_queue) table.clear(remove_queue)
open_scope(subowner) push_scope(subowner)
-- process new or changed values -- process new or changed values
for i, v in next, data do for i, v in next, data do
@ -67,23 +64,22 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
if cv ~= v then if cv ~= v then
if cv == nil then -- create new scope and run transform if cv == nil then -- create new scope and run transform
local scope = create_node(false, false) local scope = create_node(subowner, false, false)
scopes[i] = scope :: Node<any> scopes[i] = scope :: Node<any>
local node = create_start_node(v) local node = create_source_node(v)
set_owner(scope, subowner) push_scope(scope)
open_scope(scope)
local ok, result = pcall(transform, function() local ok, result = pcall(transform, function()
track(node) push_child_to_scope(node)
return node.cache return node.cache
end, i) end, i)
close_scope() pop_scope()
if not ok then if not ok then
close_scope() -- subowner scope pop_scope() -- subowner scope
error(result, 0) error(result, 0)
end end
@ -91,14 +87,14 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
output_cache[i] = result output_cache[i] = result
else -- update source else -- update source
input_nodes[i].cache = v input_nodes[i].cache = v
update(input_nodes[i]) update_descendants(input_nodes[i])
end end
input_cache[i] = v input_cache[i] = v
end end
end end
close_scope() pop_scope()
local output_array = table.create(#scopes) local output_array = table.create(#scopes)
for _, v in next, output_cache do for _, v in next, output_cache do
@ -109,29 +105,26 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
return output_array return output_array
end end
local node = create_node(false :: any, function() local node = create_node(owner, function()
return update_children(input()) return update_children(input())
end) end, false :: any)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
return function() return function()
track(node) push_child_to_scope(node)
return node.cache return node.cache
end end
end end
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO } local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = assert_owning_scope() local owner = assert_stable_scope()
local subowner = create_node(owner, false, false)
local subowner = create_node(false, false)
set_owner(subowner, owner)
local cur_input_cache_up = {} :: Map<VI, K> local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K> local new_input_cache_up = {} :: Map<VI, K>
local output_cache = {} :: Map<VI, VO> local output_cache = {} :: Map<VI, VO>
local input_nodes = {} :: Map<VI, StartNode<K>> local input_nodes = {} :: Map<VI, SourceNode<K>>
local scopes = {} :: Map<VI, Node<unknown>> local scopes = {} :: Map<VI, Node<unknown>>
local function update_children(data: Map<K, VI>) local function update_children(data: Map<K, VI>)
@ -147,7 +140,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
end end
end end
open_scope(subowner) push_scope(subowner)
-- process data -- process data
for i, v in next, data do for i, v in next, data do
@ -156,23 +149,22 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local cv = cur_input_cache[v] local cv = cur_input_cache[v]
if cv == nil then -- create new scope and run transform if cv == nil then -- create new scope and run transform
local scope = create_node(false, false) local scope = create_node(subowner, false, false)
scopes[v] = scope :: Node<any> scopes[v] = scope :: Node<any>
local node = create_start_node(i) local node = create_source_node(i)
set_owner(scope, subowner) push_scope(scope)
open_scope(scope)
local ok, result = pcall(transform, v, function() local ok, result = pcall(transform, v, function()
track(node) push_child_to_scope(node)
return node.cache return node.cache
end) end)
close_scope() pop_scope()
if not ok then if not ok then
close_scope() -- subowner scope pop_scope() -- subowner scope
error(result, 0) error(result, 0)
end end
@ -181,14 +173,14 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
else -- update source else -- update source
if cv ~= i then if cv ~= i then
input_nodes[v].cache = i input_nodes[v].cache = i
update(input_nodes[v]) update_descendants(input_nodes[v])
end end
cur_input_cache[v] = nil cur_input_cache[v] = nil
end end
end end
close_scope() pop_scope()
-- remove old values -- remove old values
for v in next, cur_input_cache do for v in next, cur_input_cache do
@ -212,15 +204,14 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
return output_array return output_array
end end
local node = create_node(false :: any, function() local node = create_node(owner, function()
return update_children(input()) return update_children(input())
end) end, false :: any)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
return function() return function()
track(node) push_child_to_scope(node)
return node.cache return node.cache
end end
end end

View file

@ -4,14 +4,14 @@ local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_node = graph.create_node local create_node = graph.create_node
local open_scope = graph.open_scope local push_scope = graph.push_scope
local close_scope = graph.close_scope local pop_scope = graph.pop_scope
local destroy = graph.destroy local destroy = graph.destroy
local refs = {} local refs = {}
local function root<T...>(fn: (destroy: () -> ()) -> T...): T... local function root<T...>(fn: (destroy: () -> ()) -> T...): T...
local node = create_node(false, false) local node = create_node(false, false, false)
refs[node] = true -- prevent gc of root node refs[node] = true -- prevent gc of root node
@ -21,15 +21,16 @@ local function root<T...>(fn: (destroy: () -> ()) -> T...): T...
destroy(node) destroy(node)
end end
open_scope(node) push_scope(node)
local result = { pcall(fn, destroy) } local function efn(err: string) return debug.traceback(err, 3) end
local result = { xpcall(fn, efn, destroy) }
close_scope() pop_scope()
if not result[1] then if not result[1] then
refs[node] = nil refs[node] = nil
throw(`mount error\n{result[2]}`) throw(`error while running root():\n\n{result[2]}`)
end end
return unpack(result :: any, 2) return unpack(result :: any, 2)

View file

@ -2,18 +2,18 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_start_node = graph.create_start_node local create_source_node = graph.create_source_node
local track = graph.track local push_child_to_scope = graph.push_child_to_scope
local update = graph.update local update_descendants = graph.update_descendants
export type Source<T> = (() -> T) & ((value: T) -> T) export type Source<T> = (() -> T) & ((value: T) -> T)
local function source<T>(initial_value: T): Source<T> local function source<T>(initial_value: T): Source<T>
local node = create_start_node(initial_value) local node = create_source_node(initial_value)
return function(...): T return function(...): T
if select("#", ...) == 0 then -- no args were given if select("#", ...) == 0 then -- no args were given
track(node) push_child_to_scope(node)
return node.cache return node.cache
end end
@ -23,7 +23,7 @@ local function source<T>(initial_value: T): Source<T>
end end
node.cache = v node.cache = v
update(node) update_descendants(node)
return v return v
end end
end end

View file

@ -24,14 +24,13 @@ Unsupported datatypes:
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T> type SourceNode<T> = graph.SourceNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node local create_source_node = graph.create_source_node
local assert_owning_scope = graph.assert_owning_scope local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local update = graph.update local update_descendants = graph.update_descendants
local set_owner = graph.set_owner local push_child_to_scope = graph.push_child_to_scope
local track = graph.track
local UPDATE_RATE = 120 local UPDATE_RATE = 120
local TOLERANCE = 0.0001 local TOLERANCE = 0.0001
@ -111,7 +110,7 @@ local vec6_to_type = {
end :: Vec6ToType<CFrame>, end :: Vec6ToType<CFrame>,
Color3 = function(v) Color3 = function(v)
return Color3.new(v.X, v.Y, v.Z) return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1))
end :: Vec6ToType<Color3>, end :: Vec6ToType<Color3>,
UDim = function(v) UDim = function(v)
@ -146,11 +145,11 @@ setmetatable(vec6_to_type, invalid_type)
-- maps spring data to its corresponding output node -- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node -- lifetime of spring data is tied to output node
local springs: { [SpringData<any>]: StartNode<any> } = {} local springs: { [SpringData<any>]: SourceNode<any> } = {}
setmetatable(springs, { __mode = "v" }) setmetatable(springs, { __mode = "v" })
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T
local owner = assert_owning_scope() local owner = assert_stable_scope()
-- https://en.wikipedia.org/wiki/Damping -- https://en.wikipedia.org/wiki/Damping
@ -182,7 +181,7 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
source_value = false :: any, source_value = false :: any,
} }
local output = create_start_node(false :: any) local output = create_source_node(false :: any)
local function updater_effect() local function updater_effect()
local value = source() local value = source()
@ -192,9 +191,8 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
return value return value
end end
local updater = create_node(false :: any, updater_effect) local updater = create_node(owner, updater_effect, false :: any)
set_owner(updater, owner)
evaluate_node(updater) evaluate_node(updater)
-- set initial position to goal -- set initial position to goal
@ -204,7 +202,7 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
output.cache = data.source_value output.cache = data.source_value
return function() return function()
track(output) push_child_to_scope(output)
return output.cache return output.cache
end end
end end
@ -269,7 +267,7 @@ local function update_spring_sources()
output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456)
end end
update(output) update_descendants(output)
end end
for _, data in next, remove_queue do for _, data in next, remove_queue do

View file

@ -3,20 +3,19 @@ if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T> type SourceNode<T> = graph.SourceNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner local push_child_to_scope = graph.push_child_to_scope
local track = graph.track
local destroy = graph.destroy local destroy = graph.destroy
local assert_owning_scope = graph.assert_owning_scope local assert_stable_scope = graph.assert_stable_scope
local open_scope = graph.open_scope local push_scope = graph.push_scope
local close_scope = graph.close_scope local pop_scope = graph.pop_scope
type Map<K, V> = { [K]: V } type Map<K, V> = { [K]: V }
local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U? local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U?
local owner = assert_owning_scope() local owner = assert_stable_scope()
return function(map) return function(map)
local last_scope: Node<false>? local last_scope: Node<false>?
@ -38,28 +37,26 @@ local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> ()
throw "map must map a value to a function" throw "map must map a value to a function"
end end
local new_scope = create_node(false, false) local new_scope = create_node(owner, false, false)
last_scope = new_scope :: Node<any> last_scope = new_scope :: Node<any>
set_owner(new_scope, owner) push_scope(new_scope)
open_scope(new_scope)
local ok, result = pcall(component) local ok, result = pcall(component)
close_scope() pop_scope()
if not ok then error(result, 0) end if not ok then error(result, 0) end
return result return result
end end
local node = create_node(nil :: U?, update) local node = create_node(owner, update, nil)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
return function() return function()
track(node) push_child_to_scope(node)
return node.cache return node.cache
end end
end end

View file

@ -1,9 +1,7 @@
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
local trace = require(script.Parent.trace) local function VIDE_ASSERT(msg): any
error(msg, 0)
local function throw(msg): any
error(msg, trace() - 1)
end end
return throw return VIDE_ASSERT

View file

@ -1,29 +0,0 @@
-- returns path to file as an array with each directory
-- accounts for Roblox and Luau contexts
local function get_path(s)
if string.sub(s, #s - 4, #s) == ".luau" then
s = string.sub(s, 1, #s - 5)
end
return string.split(s, string.match(s, "%w+/") and "/" or ".")
end
-- get directory of vide root
local root do
local path = get_path(debug.info(1, "s"))
root = path[#path - 1]
end
-- finds the first stack depth outside of any vide library function
return function(): number
local stack = 1
local path = get_path(debug.info(stack, "s"))
while path[#path] == root or path[#path - 1] == root do
stack += 1
path = get_path(debug.info(stack, "s"))
end
return stack
end

View file

@ -26,7 +26,6 @@ end
local N = 2^18 -- 262144 local N = 2^18 -- 262144
TITLE "sources" TITLE "sources"
BENCH("create source", function() BENCH("create source", function()
@ -449,27 +448,6 @@ end)
N *= 1024 N *= 1024
TITLE "cleanup"
ROOT_BENCH("register new cleanup", function()
local cleanup = cleanup
local cleaner = function() end
local callers = {}
for i = 1, N do
callers[i] = function(fn, v)
fn(v)
return i -- return unique upvalue to ensure unique closure
end
end
for i = 1, START(N) do
callers[i](cleanup, cleaner)
end
end)
TITLE "aggregate" TITLE "aggregate"
do do

View file

@ -35,28 +35,27 @@ vide.strict = false
TEST("graph", function() TEST("graph", function()
local create_node = graph.create_node local create_node = graph.create_node
local track = graph.track local push_child_to_scope = graph.push_child_to_scope
local update = graph.update local update_descendants = graph.update_descendants
local add_child = graph.add_child local push_child = graph.push_child
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local push_scope = graph.push_scope
local close_scope = graph.close_scope local pop_scope = graph.pop_scope
local set_owner = graph.set_owner
local get_children = graph.get_children local get_children = graph.get_children
local add_cleanup = graph.add_cleanup local push_cleanup = graph.push_cleanup
local destroy = graph.destroy local destroy = graph.destroy
local function node<T>(v: T?) local function node<T>(owner: Node<any>?, v: T?)
return create_node(v or false, function(x) return not x end) return create_node(owner or false, function(x) return not x end, v or false :: any)
end end
local function scope() local function scope(owner: Node<any>?)
return create_node(false, false) return create_node(owner or false, false, false)
end end
local function cleanup(fn: () -> ()) local function cleanup(fn: () -> ())
local node = assert(get_scope()) local node = assert(get_scope())
add_cleanup(node, fn) push_cleanup(node, fn)
end end
do CASE "link nodes" do CASE "link nodes"
@ -64,12 +63,12 @@ TEST("graph", function()
local b = node() local b = node()
local c = node() local c = node()
open_scope(c) push_scope(c)
track(a) push_child_to_scope(a)
track(b) push_child_to_scope(b)
close_scope() pop_scope()
CHECK(get_children(a)[1] == c) CHECK(get_children(a)[1] == c)
CHECK(get_children(b)[1] == c) CHECK(get_children(b)[1] == c)
@ -78,33 +77,30 @@ TEST("graph", function()
do CASE "rerun linked nodes" do CASE "rerun linked nodes"
local root = node() local root = node()
local a = node() local a = node()
local b = node() local b = node(root)
local c = node() local c = node(root)
set_owner(b, root)
set_owner(c, root)
local count = 0 local count = 0
local function effect(x) local function effect(x)
track(a) push_child_to_scope(a)
track(b) push_child_to_scope(b)
count += 1 count += 1
return not x return not x
end end
c.effect = effect c.effect = effect
open_scope(c) push_scope(c)
effect(c.cache) effect(c.cache)
close_scope() pop_scope()
CHECK(count == 1) CHECK(count == 1)
update(a) update_descendants(a)
CHECK(count == 2) CHECK(count == 2)
update(b) update_descendants(b)
CHECK(count == 3) CHECK(count == 3)
end end
@ -112,22 +108,18 @@ TEST("graph", function()
-- a -> b -> d -- a -> b -> d
-- -> c -- -> c
local root = node() local root = node()
local a, b, c, d = node(), node(), node(), node() local a, b, c, d = node(), node(root), node(root), node(root)
set_owner(b, root)
set_owner(c, root)
set_owner(d, root)
local b_cnt, c_cnt, d_cnt = 0, 0, 0 local b_cnt, c_cnt, d_cnt = 0, 0, 0
function b.effect(x) b_cnt += 1; return not x end function b.effect(x) b_cnt += 1; return not x end
function c.effect(x) c_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end
function d.effect(x) d_cnt += 1; return not x end function d.effect(x) d_cnt += 1; return not x end
open_scope(b); track(a); close_scope() push_scope(b); push_child_to_scope(a); pop_scope()
open_scope(c); track(a); close_scope() push_scope(c); push_child_to_scope(a); pop_scope()
open_scope(d); track(b); track(c); close_scope() push_scope(d); push_child_to_scope(b); push_child_to_scope(c); pop_scope()
update(a) update_descendants(a)
CHECK(b_cnt == 1) CHECK(b_cnt == 1)
CHECK(c_cnt == 1) CHECK(c_cnt == 1)
@ -136,21 +128,17 @@ TEST("graph", function()
do CASE "duplicate child on rerun" do CASE "duplicate child on rerun"
local root = node() local root = node()
local a, b, c = node(), node(), node() local a, b, c = node(root), node(root), node(root)
set_owner(a, root)
set_owner(b, root)
set_owner(c, root)
function c.effect(x) function c.effect(x)
track(a) push_child_to_scope(a)
track(b) push_child_to_scope(b)
return not x return not x
end end
open_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); close_scope() push_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); pop_scope()
update(a) update_descendants(a)
CHECK(#get_children(a) == 1) CHECK(#get_children(a) == 1)
CHECK(#get_children(b) == 1) CHECK(#get_children(b) == 1)
@ -159,13 +147,13 @@ TEST("graph", function()
do CASE "case 1" do CASE "case 1"
-- construct graph -- construct graph
local items = node { "a", "b" } local items = node(nil, { "a", "b" })
local selected = node "a" local selected = node(nil, "a")
local root = scope() local root = scope()
local scope1 = scope() local scope1 = scope(root)
local scope2 = scope() local scope2 = scope(root)
local items_updated local items_updated
@ -180,41 +168,36 @@ TEST("graph", function()
end) end)
end end
do open_scope(root) do push_scope(root)
clean "root" clean "root"
items_updated = node() items_updated = node(root)
track(items_updated) -- should not push_child_to_scope(items_updated) -- should not
set_owner(items_updated, root) do push_scope(items_updated)
do open_scope(items_updated) push_child_to_scope(items)
track(items)
do open_scope(root) do push_scope(root)
set_owner(scope1, root) do push_scope(scope1)
do open_scope(scope1)
clean "scope1" clean "scope1"
bind1 = node() bind1 = node(scope1)
set_owner(bind1, scope1) do push_scope(bind1)
do open_scope(bind1)
clean "bind1" clean "bind1"
track(selected) push_child_to_scope(selected)
close_scope() end pop_scope() end
close_scope() end pop_scope() end
set_owner(scope2, root) do push_scope(scope2)
do open_scope(scope2)
clean "scope2" clean "scope2"
bind2 = node() bind2 = node(scope2)
set_owner(bind2, scope2) do push_scope(bind2)
do open_scope(bind2)
clean "bind2" clean "bind2"
track(selected) push_child_to_scope(selected)
close_scope() end pop_scope() end
close_scope() end pop_scope() end
close_scope() end pop_scope() end
close_scope() end pop_scope() end
close_scope() end pop_scope() end
-- verify graph -- verify graph
@ -235,8 +218,8 @@ TEST("graph", function()
do do
local c = get_children(selected) local c = get_children(selected)
CHECK(#c == 2) CHECK(#c == 2)
CHECK(table.find(c, bind1)) CHECK(table.find(c, bind1 :: any))
CHECK(table.find(c, bind2)) CHECK(table.find(c, bind2 :: any))
end end
do do
@ -267,7 +250,7 @@ TEST("graph", function()
end end
do CASE "nodes garbage collection" do CASE "nodes garbage collection"
local wref = weak { node(1) } local wref = weak { node(nil, 1) }
destroy(wref[1]) destroy(wref[1])
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
@ -294,30 +277,23 @@ TEST("graph", function()
^ ^
depth=1 depth=1
_, _ <- attempt to update nothing _, _ <- attempt to update_descendants nothing
^ ^
]] ]]
local a, b, c, d, e, f = node(), node(), node(), node(), node(), node()
local root = node() local root = node()
set_owner(a, root) local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(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) function b.effect(x)
update(d) update_descendants(d)
return not x return not x
end end
add_child(a, b); add_child(a, c) push_child(a, b); push_child(a, c)
add_child(d, e); add_child(d, f) push_child(d, e); push_child(d, f)
update(a) update_descendants(a)
CHECK(true) CHECK(true)
end end
@ -1924,7 +1900,7 @@ TEST("read()", wrap_root(function()
CHECK(read(src) == 1) CHECK(read(src) == 1)
end end
do CASE "track source" do CASE "push_child_to_scope source"
local src = source(0) local src = source(0)
local count = 0 local count = 0
@ -2285,6 +2261,27 @@ TEST("strict", wrap_root(function()
src(not src()) src(not src())
CHECK(count == 4) CHECK(count == 4)
end end
do CASE "effect using derived source"
local input = source(true)
local output = derive(function()
return input()
end)
local count = 0
effect(function()
output()
count += 1
end)
CHECK(count == 2)
input(false)
CHECK(count == 4)
end
end)) end))
local ok = FINISH() local ok = FINISH()