This commit is contained in:
aaron 2024-06-20 17:36:25 +01:00
parent 67e87110c7
commit f2de9b0e63
25 changed files with 315 additions and 364 deletions

View file

@ -43,9 +43,9 @@ export default withMermaid({
{ text: "Components", link: "/tut/crash-course/3-components" },
{ text: "Sources", link: "/tut/crash-course/4-source" },
{ text: "Effects", link: "/tut/crash-course/5-effect" },
{ text: "Root Scopes", link: "/tut/crash-course/6-root" },
{ text: "Scopes", link: "/tut/crash-course/6-scope" },
{ text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" },
{ text: "Property Binding", link: "/tut/crash-course/8-property-binding" },
{ text: "Property Binding", link: "/tut/crash-course/8-implicit-effect" },
{ text: "Derived Sources", link: "/tut/crash-course/9-derived-source" },
{ text: "Cleanup", link: "/tut/crash-course/10-cleanup" },
{ text: "Control Flow", link: "/tut/crash-course/11-control-flow" },

View file

@ -4,7 +4,7 @@
## 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.
- **Type**

View file

@ -3,12 +3,13 @@
<br/>
:::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()
Creates and runs a function in a new reactive scope.
Creates and runs a function in a new stable scope.
- **Type**
@ -20,8 +21,8 @@ Creates and runs a function in a new reactive scope.
Returns the result of the given function.
Creates a new root reactive scope, where creation and derivations of sources
can be tracked and properly disposed of.
Creates a new stable scope, where creation of effects can be tracked and
properly disposed of.
A function to destroy the root is passed into the callback, which will run
any cleanups and allow derived sources created to garbage collect.

View file

@ -1,7 +1,6 @@
# Nested Reactive Scopes
# Nested Scopes
Nesting reactive scopes gives you finer control over the reactive graph, but
needs more work to do. The built-in control flow functions try to cover the
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
most common cases, but they do not cover all of them.
This tutorial will demonstrate how to implement a `show()` control flow function
@ -21,7 +20,7 @@ local function Counter()
}
end
mount(function()
root(function()
local toggled = source(true)
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
`show` effect calls `Counter`, which creates a new reactive scope to update its
text whenever `count` changes. As per the rules of reactive scopes, a reactive
scope rerunning will destroy any reactive scope created within it. So the text
scope rerunning will destroy any scopes created within it. So the text
effect's reactive scope is destroyed whenever the show effect is rerun.
The same can be achieved without the use of `show()`:
@ -82,7 +81,10 @@ mount(function()
effect(function()
if toggled() then
local destroy = mount(Button)
local destroy = root(function(destroy)
Counter()
return destroy
end)
cleanup(destroy)
end
end)
@ -116,11 +118,14 @@ subgraph mount
end
```
This is another way to achieve the same. Here we use `mount()` within the effect
to manually create and destroy a new reactive scope whenever the effect reruns.
This is another way to achieve the same. Here we use `root()` within the effect
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
directly within the effect:
The reason for creating a stable scope is to prevent the effect from tracking
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
local mount = vide.mount
@ -174,7 +179,9 @@ end
```
Without the use of `untrack()`, an error would occur, since Vide does not allow
the creation of reactive scopes inside reactive scopes that are tracking. The
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
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
@ -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
effect rerunning causes the counter's internal reactive scope to be destroyed,
making sure everything is cleaned up.

View file

@ -3,7 +3,7 @@
Sometimes you may need to do some cleanup when destroying a component or after
a side-effect from a source update. Vide provides a function `cleanup()` which
is used to queue a cleanup callback for the next time a reactive scope is rerun
or destroyed.
or destroyed, or when a stable scope is destroyed.
```lua
local mount = vide.mount
@ -31,14 +31,18 @@ local function Timer()
}
end
local unmount = mount(Timer)
local instance, destroy = root(function(destroy)
local instance = Timer()
return instance, destroy
end)
unmount() -- all queued cleanups are ran, heartbeat connection disconnected
wait(5)
destroy() -- 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.
when the scope responsible for creating the timer component is destroyed.
::: tip
Roblox instances do not need to be explicitly destroyed for their

View file

@ -6,8 +6,8 @@ known as *control flow* functions.
These functions return new sources, which hold the instances to be displayed.
Control flow functions run their components in a new reactive scope, which can
be destroyed independently of the reactive scope that called the control flow
Control flow functions run their components in a new stable scope, which can
be destroyed independently of the stable scope that called the control flow
function. This means parts of your app can be independently created and
destroyed.
@ -93,9 +93,9 @@ subgraph root["root scope"]
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.
A `switch()` call creates a new effect and a new stable 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.

View file

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

View file

@ -22,52 +22,52 @@ Anything that happens in response to a source update.
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()`
- `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()`
- `derive()`
Reactive scopes can:
Reactive scopes do track sources and will rerun when those sources update.
- track sources that are read from within.
- rerun when a tracked source updates.
- track new reactive scopes created from within.
New reactive scopes cannot be created within a reactive scope, but stable scopes
can.
## Scope Owners
A reactive scope created within another reactive scope is *owned* by the other
reactive scope, with the exception of the reactive scope created by `root()`.
A scope created within another scope is *owned* by the other scope, with the
exception of the scope created by `root()`.
When a reactive scope is rerun or destroyed, all reactive scopes owned by it are
automatically destroyed.
When a scope is rerun or destroyed, all scopes owned by it are automatically
destroyed.
`root()`, which `mount()` uses internally, creates a reactive scope with no
owner, since it must be destroyed manually using a destructor
returned.
`root()` creates a stable scope with no owner, instead it is destroyed manually.
## 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()`.
## Tracking
Sources read from within a reactive scope will be tracked. This can be disabled
using `untrack()`, which will make reactive scopes temporarily ignore sources
read.
The reactive scope created by `root()` is non-tracking by default.
As a guard against misusage, a reactive scope cannot be created within a
reactive scope, unless it is made non-tracking using `untrack()`.
## Reactive Graph
The combination of reactive scopes can viewed graphically, called a
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
relationships between effects and the sources they depend on.
@ -114,10 +114,10 @@ count --> text
Notes:
- 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
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
owned by it. `count` will be untouched and future updates
to `count` will have no effect.

View file

@ -39,10 +39,3 @@ return create "ScreenGui" {
Assign a value to a string key to set a property, and assign a value to a
number key to set a child. Events can be connected to by assigning a function
to a string key.
::: 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

@ -58,20 +58,8 @@ local function App()
}
}
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.
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,43 +1,65 @@
# Root Reactive Scopes
# Scopes
Vide operates on the concept of scopes. Vide scopes come in two flavors:
stable and reactive.
The three main rules for scopes are:
- Stable scopes never rerun.
- Reactive scopes will rerun on source updates.
- A reactive scope cannot be created within another reactive scope.
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
a stable 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.
This is the purpose of `root()`, which creates an initial stable 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.
When this root reactive scope is destroyed, it will destroy any effects created
within it, ensuring everything is cleaned up properly.
```lua
local source = vide.source
local effect = vide.effect
local function App()
local function setup()
local count = source(0)
effect(function()
print(count())
end)
return count
end
setup() -- will error since effect() was not called within a stable scope
App() -- will error since effect() was not called within a reactive scope
vide.mount(App) -- works!
local count = vide.root(setup) -- runs
count(1) -- prints "1"
```
Mounting returns a function that when called will destroy its reactive scope,
along with any other reactive scopes created inside it.
The scope created by `root()` can be destroyed by calling the function it passes
into the given function.
```lua
local unmount = mount(App)
local function setup(destroy)
local count = source(0)
unmount()
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*.
@ -65,7 +87,7 @@ subgraph root
end
```
When the root reactive scope created by `mount()` is destroyed, the `effect`
When the root reactive scope created by `root()` 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
@ -75,6 +97,6 @@ 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
effects within a top-level `root()` 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

@ -27,8 +27,6 @@ local function Counter()
return instance
end
mount(Counter, game.StarterGui)
```
Above is an example of a counter component, that when clicked, will increment
@ -37,9 +35,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
count source is created inside the component.
We use `mount()` to create the counter within a reactive scope, which also takes
a second argument to parent the counter to another instance.
## External State
External sources can also be passed into components for them to use.
@ -72,4 +67,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
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,4 +1,4 @@
# Property Binding
# Implicit Effects
Explicitly creating effects to update properties can be tedious. Vide provides a
way to *implicitly* create an effect to update properties.
@ -31,12 +31,7 @@ source used within is updated.
Just like effects, the function is ran immediately in a reactive scope to set
the property initially and determine what sources are being used.
This allows you as the programmer to not need to manually update UI as the state
of your program changes. You just define how data sources map to UI, and Vide's
reactive system will automatically update any properties depending on those
sources.
## Children Binding
## Children
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

View file

@ -36,7 +36,7 @@ source(1) -- prints "ran" x2
```
To avoid this, you can use `derive()` to derive a new source instead. This will
run a callback in a new reactive scope only when a dependent source has updated.
run a function in a new reactive scope only when a dependent source has updated.
Reading this derived source multiple times will just return a cached result from
when it last updated.
@ -58,7 +58,7 @@ effect(function() text() end)
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
not rerun the effects using it.

View file

@ -5,9 +5,8 @@ local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
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 set_owner = graph.set_owner
function create_binding<T>(updater: (T) -> T, binding: T)
if flags.strict then
@ -31,12 +30,7 @@ function create_binding<T>(updater: (T) -> T, binding: T)
end
end
local owner = assert_owning_scope()
local node = create_node(binding, updater)
set_owner(node, owner)
evaluate_node(node)
evaluate_node(create_node(assert_stable_scope(), updater, binding))
end
type PropertyBinding = {

View file

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

View file

@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
export type StartNode<T> = {
export type SourceNode<T> = {
cache: T,
[number]: Node<T>
}
@ -16,12 +16,11 @@ export type Node<T> = {
owned: { Node<T> } | false,
owner: Node<T> | false,
parents: { StartNode<T> },
parents: { SourceNode<T> },
[number]: Node<T> -- children
}
-- reactive scope stack
local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
local scopes = { n = 0 } :: { [number]: Node<any>, n: number } -- scopes stack
local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U)
local thread = coroutine.create(pcall)
@ -40,46 +39,37 @@ local function get_scope(): Node<unknown>?
return scopes[scopes.n]
end
local function assert_owning_scope(): Node<unknown>
local function assert_stable_scope(): Node<unknown>
local scope = get_scope()
if not scope then
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
throw("cannot create new reactive scope in a tracking reactive scope")
throw("cannot create a new reactive scope inside another reactive scope")
end
return scope
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(child.parents, parent)
end
local function set_owner(node: Node<any>, owner: Node<any>)
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 function push_scope<T>(node: Node<T>)
local n = scopes.n + 1
scopes.n = n
scopes[n] = node
end
local function close_scope()
local function pop_scope()
local n = scopes.n
scopes.n = n - 1
scopes[n] = nil
end
local function add_cleanup<T>(node: Node<T>, cleanup: () -> ())
local function push_cleanup<T>(node: Node<T>, cleanup: () -> ())
if node.cleanups then
table.insert(node.cleanups, cleanup)
else
@ -87,34 +77,35 @@ local function add_cleanup<T>(node: Node<T>, cleanup: () -> ())
end
end
local function run_cleanups<T>(node: Node<T>)
local function flush_cleanups<T>(node: Node<T>)
if node.cleanups then
for _, fn in next, node.cleanups do
local ok, err: string? = pcall(fn)
if not ok then throw(`cleanup error: {err}`) end
end
table.clear(node.cleanups)
end
end
local function 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
t[idx] = t[n]
t[i] = t[n]
t[n] = nil
end
local function unparent<T>(node: Node<T>)
local parents = node.parents
for i, parent in next, parents do
for i, parent in parents do
find_and_swap_pop(parent, node)
parents[i] = nil
end
end
local function destroy<T>(node: Node<T>)
run_cleanups(node)
flush_cleanups(node)
unparent(node)
if node.owner then
@ -141,28 +132,28 @@ local function evaluate_node<T>(node: Node<T>)
local cur_value = node.cache
if flags.strict then
run_cleanups(node)
flush_cleanups(node)
destroy_owned(node)
open_scope(node)
push_scope(node)
local ok, new_value = ycall(node.effect :: (T) -> T, cur_value)
close_scope()
pop_scope()
if not ok then throw(new_value :: string) end
node.cache = new_value :: T
end
run_cleanups(node)
flush_cleanups(node)
destroy_owned(node)
open_scope(node)
push_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, node.cache)
close_scope()
pop_scope()
if not ok then
table.clear(update_queue)
@ -175,7 +166,7 @@ local function evaluate_node<T>(node: Node<T>)
return cur_value ~= new_value
end
local function queue_children<T>(node: StartNode<T>)
local function queue_children_for_update<T>(node: SourceNode<T>)
local i = update_queue.n
while node[1] do
i += 1
@ -198,7 +189,7 @@ local function flush_update_queue()
--assert(node.effect)
if node.owner and evaluate_node(node) then
queue_children(node)
queue_children_for_update(node)
end
update_queue[i] = false :: any
@ -210,9 +201,9 @@ local function flush_update_queue()
_flushing = false
end
local function update<T>(root: StartNode<T>)
local function update_descendants<T>(root: SourceNode<T>)
local n0 = update_queue.n
queue_children(root)
queue_children_for_update(root)
if flags.batch then return end
@ -223,7 +214,7 @@ local function update<T>(root: StartNode<T>)
-- check if node is still owned in case destroyed after queued
if node.owner and evaluate_node(node) then
queue_children(node)
queue_children_for_update(node)
end
update_queue[i] = false :: any -- false instead of nil to avoid sparse
@ -233,27 +224,37 @@ local function update<T>(root: StartNode<T>)
update_queue.n = n0
end
local function track<T>(node: StartNode<T>)
local function push_child_to_scope<T>(node: SourceNode<T>)
local scope = get_scope()
if scope and scope.effect then -- do not track nodes with no effect
add_child(node, scope)
push_child(node, scope)
end
end
local function create_node<T>(value: T, effect: false | (T) -> T): Node<T>
return {
local function create_node<T>(owner: false | Node<any>, effect: false | (T) -> T, value: T): Node<T>
local node: Node<T> = {
cache = value,
effect = effect,
cleanups = false,
owner = false,
owner = owner,
owned = false,
parents = {},
}
if owner then
if owner.owned then
table.insert(owner.owned, node)
else
owner.owned = { node }
end
end
return node
end
local function create_start_node<T>(value: T): StartNode<T>
local function create_source_node<T>(value: T): SourceNode<T>
return { cache = value }
end
@ -262,20 +263,19 @@ local function get_children<T>(node: Node<T>): { Node<unknown> }
end
return table.freeze {
open_scope = open_scope,
close_scope = close_scope,
push_scope = push_scope,
pop_scope = pop_scope,
evaluate_node = evaluate_node,
get_scope = get_scope,
assert_owning_scope = assert_owning_scope,
add_cleanup = add_cleanup,
set_owner = set_owner,
assert_stable_scope = assert_stable_scope,
push_cleanup = push_cleanup,
destroy = destroy,
run_cleanups = run_cleanups,
track = track,
update = update,
add_child = add_child,
flush_cleanups = flush_cleanups,
push_child_to_scope = push_child_to_scope,
update_descendants = update_descendants,
push_child = push_child,
create_node = create_node,
create_start_node = create_start_node,
create_source_node = create_source_node,
get_children = get_children,
flush_update_queue = flush_update_queue,
scopes = scopes

View file

@ -4,15 +4,14 @@ local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
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_start_node = graph.create_start_node
local set_owner = graph.set_owner
local track = graph.track
local update = graph.update
local assert_owning_scope = graph.assert_owning_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local create_source_node = graph.create_source_node
local push_child_to_scope = graph.push_child_to_scope
local update_descendants = graph.update_descendants
local assert_stable_scope = graph.assert_stable_scope
local push_scope = graph.push_scope
local pop_scope = graph.pop_scope
local evaluate_node = graph.evaluate_node
local destroy = graph.destroy
@ -28,14 +27,12 @@ local function check_primitives(t: {})
end
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = assert_owning_scope()
local subowner = create_node(false, false)
set_owner(subowner, owner)
local owner = assert_stable_scope()
local subowner = create_node(owner, false, false)
local input_cache = {} :: Map<K, VI>
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 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)
open_scope(subowner)
push_scope(subowner)
-- process new or changed values
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 == 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>
local node = create_start_node(v)
local node = create_source_node(v)
set_owner(scope, subowner)
open_scope(scope)
push_scope(scope)
local ok, result = pcall(transform, function()
track(node)
push_child_to_scope(node)
return node.cache
end, i)
close_scope()
pop_scope()
if not ok then
close_scope() -- subowner scope
pop_scope() -- subowner scope
error(result, 0)
end
@ -91,14 +87,14 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
output_cache[i] = result
else -- update source
input_nodes[i].cache = v
update(input_nodes[i])
update_descendants(input_nodes[i])
end
input_cache[i] = v
end
end
close_scope()
pop_scope()
local output_array = table.create(#scopes)
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
end
local node = create_node(false :: any, function()
local node = create_node(owner, function()
return update_children(input())
end)
set_owner(node, owner)
end, false :: any)
evaluate_node(node)
return function()
track(node)
push_child_to_scope(node)
return node.cache
end
end
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = assert_owning_scope()
local subowner = create_node(false, false)
set_owner(subowner, owner)
local owner = assert_stable_scope()
local subowner = create_node(owner, false, false)
local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K>
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 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
open_scope(subowner)
push_scope(subowner)
-- process data
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]
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>
local node = create_start_node(i)
local node = create_source_node(i)
set_owner(scope, subowner)
open_scope(scope)
push_scope(scope)
local ok, result = pcall(transform, v, function()
track(node)
push_child_to_scope(node)
return node.cache
end)
close_scope()
pop_scope()
if not ok then
close_scope() -- subowner scope
pop_scope() -- subowner scope
error(result, 0)
end
@ -181,14 +173,14 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
else -- update source
if cv ~= i then
input_nodes[v].cache = i
update(input_nodes[v])
update_descendants(input_nodes[v])
end
cur_input_cache[v] = nil
end
end
close_scope()
pop_scope()
-- remove old values
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
end
local node = create_node(false :: any, function()
local node = create_node(owner, function()
return update_children(input())
end)
set_owner(node, owner)
end, false :: any)
evaluate_node(node)
return function()
track(node)
push_child_to_scope(node)
return node.cache
end
end

View file

@ -4,14 +4,14 @@ local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create_node = graph.create_node
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local push_scope = graph.push_scope
local pop_scope = graph.pop_scope
local destroy = graph.destroy
local refs = {}
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
@ -21,11 +21,11 @@ local function root<T...>(fn: (destroy: () -> ()) -> T...): T...
destroy(node)
end
open_scope(node)
push_scope(node)
local result = { pcall(fn, destroy) }
close_scope()
pop_scope()
if not result[1] then
refs[node] = nil

View file

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

View file

@ -24,14 +24,13 @@ Unsupported datatypes:
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
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_start_node = graph.create_start_node
local assert_owning_scope = graph.assert_owning_scope
local create_source_node = graph.create_source_node
local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node
local update = graph.update
local set_owner = graph.set_owner
local track = graph.track
local update_descendants = graph.update_descendants
local push_child_to_scope = graph.push_child_to_scope
local UPDATE_RATE = 120
local TOLERANCE = 0.0001
@ -146,11 +145,11 @@ setmetatable(vec6_to_type, invalid_type)
-- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node
local springs: { [SpringData<any>]: StartNode<any> } = {}
local springs: { [SpringData<any>]: SourceNode<any> } = {}
setmetatable(springs, { __mode = "v" })
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
@ -182,7 +181,7 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
source_value = false :: any,
}
local output = create_start_node(false :: any)
local output = create_source_node(false :: any)
local function updater_effect()
local value = source()
@ -192,9 +191,8 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
return value
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)
-- 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
return function()
track(output)
push_child_to_scope(output)
return output.cache
end
end
@ -269,7 +267,7 @@ local function update_spring_sources()
output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456)
end
update(output)
update_descendants(output)
end
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 graph = require(script.Parent.graph)
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 evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local track = graph.track
local push_child_to_scope = graph.push_child_to_scope
local destroy = graph.destroy
local assert_owning_scope = graph.assert_owning_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local assert_stable_scope = graph.assert_stable_scope
local push_scope = graph.push_scope
local pop_scope = graph.pop_scope
type Map<K, V> = { [K]: V }
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)
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"
end
local new_scope = create_node(false, false)
local new_scope = create_node(owner, false, false)
last_scope = new_scope :: Node<any>
set_owner(new_scope, owner)
open_scope(new_scope)
push_scope(new_scope)
local ok, result = pcall(component)
close_scope()
pop_scope()
if not ok then error(result, 0) end
return result
end
local node = create_node(nil :: U?, update)
local node = create_node(owner, update, nil)
set_owner(node, owner)
evaluate_node(node)
return function()
track(node)
push_child_to_scope(node)
return node.cache
end
end

View file

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