Merge reactive scope refactor

This commit is contained in:
aaron 2023-09-15 12:54:42 +01:00
parent 0e439f084f
commit efc4798ddb
48 changed files with 2750 additions and 1949 deletions

View file

@ -26,6 +26,7 @@ export default defineConfig({
items: [
{ text: "Reactivity: Core", link: "/api/reactivity-core" },
{ text: "Reactivity: Utility", link: "/api/reactivity-utility" },
{ text: "Reactivity: Control Flow", link: "/api/reactivity-flow" },
{ text: "Element Creation", link: "/api/creation" },
{ text: "Animation", link: "/api/animation" },
{ text: "Strict Mode", link: "/api/strict-mode" },
@ -41,16 +42,26 @@ export default defineConfig({
{ text: "Element Creation", link: "/tut/crash-course/2-creation" },
{ text: "Components", link: "/tut/crash-course/3-components" },
{ text: "Source", link: "/tut/crash-course/4-source" },
{ text: "Derived Source", link: "/tut/crash-course/5-derived-source" },
{ text: "Table Source", link: "/tut/crash-course/6-table-source" },
{ text: "Nested Properties", link: "/tut/crash-course/7-nested-properties" },
{ text: "Actions", link: "/tut/crash-course/8-actions" },
{ text: "Effect", link: "/tut/crash-course/5-effect" },
{ text: "Derived Source", link: "/tut/crash-course/6-derived-source" },
{ text: "Cleanup", link: "/tut/crash-course/7-cleanup" },
{ text: "Control Flow", link: "/tut/crash-course/8-control-flow" },
{ text: "Property Nesting", link: "/tut/crash-course/9-property-nesting" },
{ text: "Actions", link: "/tut/crash-course/10-actions" },
]
},
{
text: "Tutorials",
text: "Control Flow WIP",
items: [
{ text: "Crash Course", link: "/tut/crash-course/index" },
{ text: "switch", link: "/tut/control-flow/switch.md" },
{ text: "indexes", link: "/tut/control-flow/indexes.md" },
{ text: "values", link: "/tut/control-flow/values.md" },
]
},
{
text: "Advanced Reactivity WIP",
items: [
{ text: "reactive-scopes", link: "/tut/reactive-scoping.md"}
]
}
],

View file

@ -2,6 +2,38 @@
<br/>
## mount()
Runs a function and applies its result to a target instance.
- **Type**
```lua
function mount<T>(component: () -> T, target: Instance?): () -> ()
```
- **Details**
The result of the function is applies to the target in the same way
properties are using `create()`.
The function is ran in a new reactive scope, just like
[root()](reactivity-core.md#root).
Returns a function that when called will destroy the reactive scope.
- **Example**
```lua
local function App()
return create "ScreenGui" {
create "TextLabel" { Text = "Vide" }
}
end
mount(App, game.StarterGui)
```
## create()
Creates a new UI element, applying any given properties.
@ -28,19 +60,18 @@ Creates a new UI element, applying any given properties.
- **Property setting rules**
- If a table index is a string:
- If its value is a table then it will attempt to perform aggregate
initialization.
- If its value is a function then it will either bind that property to
the function or connect it if the property type is a `RBXScriptSignal`.
- If the value is not a function then the property will be set to that
value.
- If a table index is a number:
- If its value is a table then that table will be recursively
- processed just like the outer table.
- If its value is a function then it will parent and bind any instances
returned by that function as children.
- If its value is an action then that action will be queued to run after
properties are set.
- If its value is a table then that table will be recursively
processed just like the outer table.
- If its value is a function then it will bind the instances children to
that function.
- If its value is an instance then it will be parented to the instance.
- **Example**
@ -54,7 +85,7 @@ Creates a new UI element, applying any given properties.
}
```
A component using property nesting/grouping.
A component using property nesting.
```lua
type Layout = {

View file

@ -2,6 +2,30 @@
<br/>
## root()
Creates and runs a function in a new reactive scope.
- **Type**
```lua
function root<T...>(fn: (destroy: () -> ()) -> T...): T...
```
- **Details**
Creates a new root reactive scope, where creation and derivations of sources
can be tracked and properly disposed of.
Returns the result of the given function.
A function to destroy the root is passed into the callback, which will run
any cleanups and allow derived sources created to garbage collect.
::: warning
`fn()` cannot yield.
:::
## source()
Creates a new source with the given value.
@ -14,10 +38,10 @@ Creates a new source with the given value.
- **Details**
Calling the returned source with no arguments will return its stored value,
calling with arguments will set a new value.
Calling the returned source with no argument will return its stored value,
calling with an argument will set a new value.
Reading from the source from within any reactive scope will cause changes
Reading from the source from within a reactive scope will cause changes
to that source to be tracked and anything depending on it to update.
- **Example**
@ -30,44 +54,39 @@ Creates a new source with the given value.
count(count() + 1) -- 1
```
## watch()
## effect()
Runs a callback on source update.
Runs a side-effect on source update.
- **Type**
```lua
function watch(source: () -> ()): Unwatch
type Unwatch = () -> ()
function effect(callback: () -> ())
```
- **Details**
The source callback is ran immediately to determine what states are
referenced.
The callback is ran immediately.
Any time a source referenced in the callback is changed, the callback will
be reran.
Also returns a function that when called, stops the watcher immediately.
::: warning
`source()` cannot yield.
`callback()` cannot yield.
:::
- **Example**
```lua
local state = source(1)
local num = source(1)
watch(function()
print(state())
effect(function()
print(num())
end)
-- prints 1
state(state() + 1)
num(num() + 1)
-- prints 2
```
@ -110,150 +129,4 @@ Derives a new source from existing sources.
text() -- "count: 1"
```
## indexes()
Maps each index in a table source to an object.
- **Type**
```lua
function indexes<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: () -> VI, index: KI) -> VO
): Array<VO>
- **Details**
The transform function is called only ever *once* for each index in the
source table. The first argument is a source containing the index's value
and the second argument is just the index.
Anytime a new index is added, the transform function will be called again
for that new index.
Anytime an existing index value changes, the transform function is not rerun,
instead the source value for that index will update, causing anything
depending on it to update too.
Returns a state containing an array of all objects returned by the
transform.
::: warning
`transform()` cannot yield.
:::
- **Example**
The intended purpose of this function is to map each index in a table to
a UI element.
```lua
type Item = {
name: string,
icon: number
}
local items = source {} :: () -> Array<Item>
local displays = indexes(items, function(item, i)
return ItemDisplay {
Name = function()
return item().name
end,
Image = function()
return "rbxassetid://" .. item().icon
end,
LayoutOrder = i
}
end)
```
## values()
Maps each value in a table source to an object.
- **Type**
```lua
function values<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: VI, index: () -> KI) -> VO
): Array<VO>
- **Details**
The transform function is called only ever *once* for each value in the
source table. The first argument is the index's value and
the second argument is a source containing the index.
Anytime a new value is added, the transform function will be called again
for that new value.
Anytime an existing value's index changes, the transform function is not
rerun, instead the source index for that value will update, causing anything
depending on it to update too.
Returns a state containing an array of all objects returned by the
transform.
::: warning
`transform()` cannot yield.
:::
::: warning
Having primitive values in the source table can cause unexpected behavior,
as duplicate primitives can result in multiple index sources being bound
to the same UI element.
:::
- **Example**
The intended purpose of this function is to map each value in a table to
a UI element.
```lua
type Item = {
name: string,
icon: number
}
local items = source {} :: () -> Array<Item>
local displays = values(items, function(item, i)
return ItemDisplay {
Name = item.Name
Image = "rbxassetid://" .. item.icon,
LayoutOrder = i
}
end)
```
- **Extra**
When should you use `indexes()` and `values()`?
`values()` should be used when you have a fixed set of objects where the
same objects can be re-arranged in the source table. It maps a value to a
UI element.
e.g.
- List of all players.
- Inventory of items.
- Chat message history.
- Toast notifications.
`indexes()` should be used in other cases, especially when your source table
has primitive value. It maps an index to a UI element.
e.g.
- List of character or weapon stats.
In most cases, both functions will appear to have the same behavior.
The main difference is performance, picking the right function to use can
result in less property updates and less re-renders.
--------------------------------------------------------------------------------

186
docs/api/reactivity-flow.md Normal file
View file

@ -0,0 +1,186 @@
# Reactivity API: Control Flow
<br/>
## switch()
Changes object based on a source and a mapping table.
- **Type**
```lua
function switch<K, V>(source: () -> K): (map: Map<K, () -> V>) -> V?
```
- **Details**
The mapped function is ran in a new reactive scope that is destroyed when
the source changes and maps to a different function.
::: warning
Mapped functions cannot yield.
:::
- **Example**
```lua
local logged = source(false)
local button = switch(logged) {
[true] = function()
return Button { Text = "Log out", Toggle = logged }
end,
[false] = function()
return Button { Text = "Log in", Toggle = logged }
end
}
```
## indexes()
Maps each index in a table source to an object.
- **Type**
```lua
function indexes<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: () -> VI, index: KI) -> VO
): Array<VO>
- **Details**
The transform function is called only ever *once* for each index in the
source table. The first argument is a source containing the index's value
and the second argument is just the index.
Anytime a new index is added, the transform function will be called again
for that new index.
Anytime an existing index value changes, the transform function is not rerun,
instead the source value for that index will update, causing anything
depending on it to update too.
Returns a state containing an array of all objects returned by the
transform.
::: warning
`transform()` cannot yield.
:::
- **Example**
The intended purpose of this function is to map each index in a table to
a UI element.
```lua
type Item = {
name: string,
icon: number
}
local items = source {} :: () -> Array<Item>
local displays = indexes(items, function(item, i)
return ItemDisplay {
Name = function()
return item().name
end,
Image = function()
return "rbxassetid://" .. item().icon
end,
LayoutOrder = i
}
end)
```
## values()
Maps each value in a table source to an object.
- **Type**
```lua
function values<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: VI, index: () -> KI) -> VO
): Array<VO>
- **Details**
The transform function is called only ever *once* for each value in the
source table. The first argument is the index's value and
the second argument is a source containing the index.
Anytime a new value is added, the transform function will be called again
for that new value.
Anytime an existing value's index changes, the transform function is not
rerun, instead the source index for that value will update, causing anything
depending on it to update too.
Returns a state containing an array of all objects returned by the
transform.
::: warning
`transform()` cannot yield.
:::
::: warning
Having primitive values in the source table can cause unexpected behavior,
as duplicate primitives can result in multiple index sources being bound
to the same UI element.
:::
- **Example**
The intended purpose of this function is to map each value in a table to
a UI element.
```lua
type Item = {
name: string,
icon: number
}
local items = source {} :: () -> Array<Item>
local displays = values(items, function(item, i)
return ItemDisplay {
Name = item.Name
Image = "rbxassetid://" .. item.icon,
LayoutOrder = i
}
end)
```
- **Extra**
When should you use `indexes()` and `values()`?
`values()` should be used when you have a fixed set of objects where the
same objects can be re-arranged in the source table. It maps a value to a
UI element.
e.g.
- List of all players.
- Inventory of items.
- Chat message history.
- Toast notifications.
`indexes()` should be used in other cases, especially when your source table
has primitive value. It maps an index to a UI element.
e.g.
- List of character or weapon stats.
In most cases, both functions will appear to have the same behavior.
The main difference is performance, picking the right function to use can
result in less property updates and less re-renders.
--------------------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
## cleanup()
Runs a callback anytime a function scope is re-ran.
Runs a callback anytime a reactive scope is re-ran.
- **Type**
@ -10,25 +10,12 @@ Runs a callback anytime a function scope is re-ran.
function cleanup(callback: () -> ())
```
- **Details**
The primary purpose of this function is to provide a means of cleaning up
side effects caused by source updates and `watch()` updates.
The stack is inspected to find the function that calls `cleanup()`. The
callback passed is called anytime the caller is re-ran, and when the caller
finally garbage collects.
::: warning
Only one `cleanup()` call is allowed per function scope.
:::
- **Example**
```lua
local data = source(1)
watch(function()
effect(function()
local label = create "TextLabel" { Text = data() }
cleanup(function()
@ -53,7 +40,7 @@ Runs a callback anytime a function scope is re-ran.
## untrack()
Gets the value of a source without reactively tracking it.
Runs a given function where any sources read will not track its reactive scope.
- **Type**
@ -82,3 +69,5 @@ Gets the value of a source without reactively tracking it.
a(1)
print(sum()) -- 2
```
--------------------------------------------------------------------------------

View file

@ -12,14 +12,14 @@ and identifying improper usage.
Currently, strict mode will:
1. Run derived sources twice a source updates.
2. Run watchers twice when a source updates.
2. Run effects twice when a source updates.
3. Throw an error if yields occur where they are not allowed.
4. Checks for `indexes()` and `values()` returning primitive values.
5. Checks for duplicate nested properties at same depth.
6. Better error reporting and stack traces.
7. Checks for multiple `cleanup()` calls in the same function scope.
By rerunning sources and watchers, any side-effects are made more apparent.
By rerunning sources and effects, any side-effects are made more apparent.
This also helps ensure that cleanups are being handled correctly.
Accidental yielding within reactive scopes can break Vide's reactive graph,

View file

@ -1,4 +1,4 @@
# Table Source
# Control Flow
Vide has specific functions for dealing with sources that store a table value.

View file

@ -0,0 +1,66 @@
# Control Flow
Vide has specific functions for dealing with sources that store a table value.
Often, you will have a table of values that will be displayed in a similar
manner. Rather than manually looping over each value to generate a corresponding
UI element, Vide provides functions `indexes()` and `values()` to do this for
you.
`indexes()` maps each *index* in a table to a UI element.
```lua
local names = source { "a", "b", "c" }
local elements = indexes(names, function(name, i)
return create "TextLabel" {
Text = function()
return "Name: " .. name()
end,
LayoutOrder = i
}
end)
```
What happens here is the given callback is only ever ran *once* for each index
in the table. The callback receives two arguments, a *source* containing the
index's value and then the index itself.
Anytime the value at a corresponding index changes, the source for that index
value is updated, causing the UI element depending on it to update too.
`values()` behaves similarly, except it maps each *value* in a table to a UI
element.
```lua
type Item = {
Name: string,
Icon: number
}
local items = source({} :: Array<Item>)
local elements = values(items, function(item, i)
return create "ImageLabel" {
Image = "rbxassetid://" .. item.Icon,
LayoutOrder = i
}
end)
```
The callback is again only ever ran *once* for each value in the table. The
callback receives two arguments, a value in the table and then a *source*
containing the value's corresponding index.
Any time a value in a table changes index, the source for that value is updated,
causing the UI element position to change.
In certain cases `values()` can cause less recalculation and rerenders than
`indexes()` like when items are re-arranged and shifted within a table.
It is important that each value in a table is unique when using `values()`,
and for this reason always using `indexes()` if a table contains primitive
values.
Both `indexes()` and `values()` return an array of all mapped UI elements.

View file

@ -0,0 +1,66 @@
# Control Flow
Vide has specific functions for dealing with sources that store a table value.
Often, you will have a table of values that will be displayed in a similar
manner. Rather than manually looping over each value to generate a corresponding
UI element, Vide provides functions `indexes()` and `values()` to do this for
you.
`indexes()` maps each *index* in a table to a UI element.
```lua
local names = source { "a", "b", "c" }
local elements = indexes(names, function(name, i)
return create "TextLabel" {
Text = function()
return "Name: " .. name()
end,
LayoutOrder = i
}
end)
```
What happens here is the given callback is only ever ran *once* for each index
in the table. The callback receives two arguments, a *source* containing the
index's value and then the index itself.
Anytime the value at a corresponding index changes, the source for that index
value is updated, causing the UI element depending on it to update too.
`values()` behaves similarly, except it maps each *value* in a table to a UI
element.
```lua
type Item = {
Name: string,
Icon: number
}
local items = source({} :: Array<Item>)
local elements = values(items, function(item, i)
return create "ImageLabel" {
Image = "rbxassetid://" .. item.Icon,
LayoutOrder = i
}
end)
```
The callback is again only ever ran *once* for each value in the table. The
callback receives two arguments, a value in the table and then a *source*
containing the value's corresponding index.
Any time a value in a table changes index, the source for that value is updated,
causing the UI element position to change.
In certain cases `values()` can cause less recalculation and rerenders than
`indexes()` like when items are re-arranged and shifted within a table.
It is important that each value in a table is unique when using `values()`,
and for this reason always using `indexes()` if a table contains primitive
values.
Both `indexes()` and `values()` return an array of all mapped UI elements.

View file

@ -3,7 +3,7 @@
This is a brief tutorial designed to give you a quick run through the usage of
Vide.
Vide is largely inspired by other UI libraries such as Solid and Fusion.
Vide is heavily inspired by [Solid](https://www.solidjs.com/).
## Why Vide?
@ -19,7 +19,29 @@ Some of the main focuses behind Vide's design choices:
- Reducing the amount of imports needed for usage by leveraging Luau's syntax
and semantics.
- Being completely typecheckable.
- Flexibility, particularly with integrating other libraries and allowing users
to use their own patterns.
- A powerful reactive system that does not interfere with the lifetime of
instances.
- Flexibility with integrating other libraries and allowing users to use their
own patterns.
- Independence from instance lifetimes.
- A powerful reactive system that can update specific properties as a result of
state changes, updates are immediate with no diffing needed.
## Structure Of A Vide App
The entry point for all Vide apps is the `mount()` function. This function
sets up Vide's reactivity system. It takes and calls a function that should
create your entire app, and will apply its result to a target.
In Vide, your app should be composed of functions, each function creates a
specific part of your app, and can be reused if needed. These functions are
called *components*.
```lua
local function App()
return create "ScreenGui" {
create "TextLabel" { Text = "hi" }
}
end
mount(App, game.StarterGui)
```

View file

@ -1,62 +1,54 @@
# Creating UI Elements
# Creating UI
Instances are created using [`create()`](../../api/creation.md#create).
```lua
local vide = require(path_to_vide)
local create = vide.create
```
Instances are created using `create()`.
`create()` returns a constructor for a class which then takes a table of
properties to assign when creating a new instance for that class.
Luau allows us to omit parentheses `()` when calling functions with string or
table literals for brevity.
table literals which Vide takes advantage of for brevity.
```lua
local frame = create "Frame" {
Name = "Background",
Position = UDim2.fromScale(0.5, 0.5)
}
```
local vide = require(vide)
local mount = vide.mount
local create = vide.create
String keys are treated as properties and integer keys are treated as child
instances.
local function App()
return create "ScreenGui" {
create "Frame" {
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromScale(0.4, 0.7),
```lua
create "ScreenGui" {
Parent = game.StarterGui,
create "TextLabel" {
Text = "hi"
},
create "Frame" {
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromScale(0.4, 0.7),
create "TextLabel" {
Text = "bye"
},
create "TextLabel" {
Text = "hi"
},
create "TextButton" {
Text = "click me",
create"TextLabel" {
Text = "bye"
Activated = function()
print "clicked!"
end
}
}
}
}
end
mount(App, game.StarterGui)
```
To connect to an event, just assign the event property a function.
Assign a value to a string key to set a property, and assign a value to a
number key to set a child. Events can be connected to by assigning a function
to a string key.
All event arguments are passed into the function.
```lua
create "TextButton" {
Activated = function()
print "clicked!"
end
}
```
You can also use a form of aggregate initialization to create datatypes instead
of explicitly typing out the class name and constructor.
You can also use a shorthand to create datatypes instead of explicitly typing
out the class name and constructor. The table will be unpacked into the `.new()`
constructor of the property's type.
```lua
create "Frame" {
@ -64,7 +56,3 @@ create "Frame" {
UDim2 = { 0.5, 0, 0.5, 0 }
}
```
When a property is assigned a table, Vide will inspect the type of the property
being assigned to, and call that type's default `new()` constructor with the
unpacked values from the assigned table.

View file

@ -5,7 +5,10 @@ Components are custom-made reusable pieces of UI made from other pieces of UI.
By using components you can make your application more modular and better
organized.
```lua
```lua [Button.luau]
local vide = require(vide)
local create = vide.create
local function Button(props: {
Position: UDim2,
Text: string,
@ -20,29 +23,44 @@ local function Button(props: {
Activated = props.Activated
}
end
return Button
```
Above is a simple example of a button component with its background color set to
a dark grey and with a fixed size.
```lua [App.luau]
local vide = require(vide)
local mount = vide.mount
local create = vide.create
local Button = require(Button)
local function App()
return create "ScreenGui" {
Button {
Position = UDim2.fromOffset(200, 200),
Text = "click me!",
Activated = function()
print "clicked"
end
}
}
end
mount(App, game.StarterGui)
```
Above is a simple example of a button component with a set color and size,
being reused across files.
A single parameter `props` is used to pass properties to the component.
Creating instances of this button component is as simple as doing the below:
```lua
local button = Button {
Position = UDim2.new(),
Text = "Click me!",
Activated = function()
print "clicked"
end
}
```
Components allow you to *encapsulate* behavior. You can only modify the
component in ways that you allow in the component.
This also promotes code reusability. Anytime you want a new button all you do
is call `Button {}` instead of creating and setting every property each time.
When changing the button in future, any changes to the button file will be
reflected anywhere the button is used throughout your app.
This can be extended to much more complicated UI.

View file

@ -4,33 +4,18 @@
core of reactivity in Vide, as updates to a source can automatically update
properties or other sources depending on that source.
A source in Vide can be created using
[`source()`](../../api/reactivity-core.md#source).
A source in Vide can be created using `source()`.
```lua
local vide = require(vide)
local source = vide.source
local count = source(0)
```
The value passed to `source()` is the initial value of the source.
The value of a source can be set by calling it with an argument, and can be read
by calling it with no arguments.
```lua
count(count() + 1) -- increment source by 1
```
Below is an example of a stateful counter component.
```lua
local function Counter(props: { Position: UDim2 })
local function Counter()
local count = source(0)
return create "TextButton" {
Position = props.Position,
Size = UDim2.new(200, 50),
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = count,
@ -39,37 +24,28 @@ local function Counter(props: { Position: UDim2 })
end
}
end
mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
```
Each call of `Counter {}` will create a new counter element, each with their own
independent count.
The value passed to `source()` is the initial value of the source.
Vide detects when you assign a function to a property. This is known
as *binding* and doing so will cause the property to *automatically* update
whenever a source in that function is updated, by rerunning the function and
assigning its return value. You can only bind non-event
properties, otherwise the function is connected as the event callback.
This allows you as the programmer to not need to manually update GUI as the state
of your program changes. You just define how the data maps to UI, and Vide's
reactive system will surgically update any properties depending on sources that
are changed.
Since sources are just functions, you can also pass external sources to
components like so:
The value of a source can be set by calling it with an argument, and can be read
by calling it with no arguments.
```lua
local function Text(p: {
Text: () -> string
})
return create "TextLabel" {
Text = p.Text
}
end
local text = source "hi"
Text {
Text = text
}
count(count() + 1) -- increment count by 1
```
Each call of `Counter {}` will create a new counter, each maintaining their
own count.
When you assign a function to a non-event property, Vide will immediately run it
and check what sources were read from. When updating those sources again after,
this function will be re-ran and its return value applied to the property.
This is known as *binding* properties.
This allows you as the programmer to not need to manually update UI as the state
of your program changes. You just define how the data maps to UI, and Vide's
reactive system will automatically update any properties depending on sources
that are updated.

View file

@ -1,102 +0,0 @@
# Derived Source
You can create new sources from existing sources. This is known as *deriving
sources*.
A function that wraps a source effectively becomes a new source. If a source
used inside a function is updated, the whole function can be re-ran to recompute
its value.
```lua
local count = source(0)
local function text()
return "count: " .. count()
end
create "TextLabel" {
Text = text
}
```
Sometimes when using expensive computations to derive state, you only want to
recalculate it once when a source state has changed
If you wrap a source with a regular function, its value will be recomputed
every time you call that function.
[`derive()`](../../api/reactivity-core.md#derive) accepts a functions whose
return value will be cached, so that subsequent calls of this derived source
will return the same cached value until one of its input sources have changed.
```lua
local derive = vide.derive
```
```lua
local count = source(0)
local factorial = derive(function()
local n = 1
for i = 2, count() do
n *= i
end
return n
end)
```
This can improve performance in cases where a source is read from multiple times
between recalculations, like in the example below:
```lua
create "TextLabel" {
Text = function()
return "factorial squared: " .. factorial() * factorial()
end
}
count(3) -- displays "factorial squared: 36"
count(4) -- displays "factorial squared: 576"
```
Vide knows what sources are being depended on by immediately running the
callback when deriving or binding sources. If a source is in a function but is
never referenced the first time it runs, Vide will not know to rerun the
function if that source changes.
An example to watch out for is when using sources within branches:
```lua
local condition = source(true)
local count1 = source(0)
local count2 = source(0)
local text = function()
if condition() then
return "text: " .. count1()
else
return "text: " .. count2()
end
end
```
In the above case, only `count1` will be referenced, meaning `text` will not be
aware of `count2` even if the condition is later set to false.
All sources to be tracked must be referenced the first time the function runs.
```lua
local condition = source(true)
local count1 = source(0)
local count2 = source(0)
local text = function()
local c1 = count1()
local c2 = count2()
if condition() then
return "text: " .. c1
else
return "text: " .. c2
end
end
```

View file

@ -0,0 +1,45 @@
# Effect
An effect is a function that is run anytime a source updates. They are called
effects because they can produce side-effects when reacting to source changes.
Effects are created using `effect()`.
```lua
local vide = require(vide)
local source = vide.source
local effect = vide.effect
local function Counter()
local count = source(0)
effect(function()
print("count has updated to: " .. count())
end)
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = count,
Activated = function()
count(count() + 1)
end
}
end
mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
```
This will print to the terminal anytime the count is changed.
`effect()` creates an explicit side-effect. There are other side-effects in the
above code sample. The setting of `Text = count` creates another side-effect;
the updating of the Text property anytime the count is changed.
All observable changes to the user are considered to be side-effects of the
reactive system.
You should not update other sources using an effect. Improper usage can lead to
unecessary updates and infinite loops.

View file

@ -0,0 +1,76 @@
# Derived Source
You can create new sources from existing sources. This is known as *deriving
sources*.
A function that wraps a source effectively becomes a new source. If a source
used inside a function is updated, the whole function can be re-ran to recompute
its value.
```lua
local vide = require(vide)
local source = vide.source
local function Counter()
local count = source(0)
local function doubled()
return count() * 2
end
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = doubled,
Activated = function()
count(count() + 1)
end
}
end
mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
```
Now the counter will increment in 2s each time it is clicked.
Sometimes when using expensive computations to derive state, you only want to
recalculate it once when a source state has changed. Although not needed in
most cases, you can use `derive()` to create a new source that will cache its
value, only recomputing when an input source has changed.
```lua
local vide = require(vide)
local source = vide.source
local derive = vide.derive
local function Counter()
local count = source(0)
local factorial = derive(function()
local n = 1
for i = 2, count() do
n *= i
end
return n
end)
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = function()
return factorial() + factorial() + factorial()
end,
Activated = function()
count(count() + 1)
end
}
end
```
This can improve performance in cases where a source is read from multiple times
between recalculations. In the above example, the factorial is only ever
calculated once each time the count changes.

View file

@ -0,0 +1,40 @@
# Cleanup
Sometimes you may need to do some cleanup when destroying a component or after
a side-effect from a source update. Vide provides a function `cleanup()` which
is used to register a cleanup callback for the next time the reactive scope
it is called in re-runs.
```lua
local vide = require(vide)
local source = vide.source
local cleanup = vide.cleanup
local function Timer()
local count = source(0)
local con = game:GetService("RunService").Heartbeat:Connect(function(dt)
count(count() + dt)
end)
cleanup(function()
con:Disconnect()
end)
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = function()
return "seconds: " .. count()
end,
}
end
mount(function() return create "ScreenGui" { Timer {} } end, game.StarterGui)
```
In the above example, this allows us to disconnect the heartbeat connection
when the timer component is destroyed, whether that is from unmounting the app
or if it is dynamically created by a control-flow function, which will be
covered next.

View file

@ -0,0 +1,95 @@
# Control Flow
Eventually you will need a way to dynamically create and destroy UI elements
resulting from state changes. Vide provides functions to help you do this,
known as *control flow* functions.
These functions return a new source, which holds the instances to be displayed.
These sources can be assigned as children, meaning the displayed children
will update when the input source updates.
One of these functions is `switch()`, used to conditionally show one of a set of
components.
```lua
local vide = require(vide)
local source = vide.source
local switch = vide.switch
local function ToggleButton(p: {
Text: string,
Toggle: (boolean) -> boolean
})
return create "TextButton" {
Size = UDim2.fromOffset(300, 300),
Text = p.Text,
Activated = function()
p.Toggle(not p.Toggle())
end
}
end
local loggedIn = source(false)
local function LoginMenu()
return Frame {
switch(loggedIn) {
[true] = function()
return ToggleButton { Text = "Log out", Toggle = loggedIn }
end,
[false] = function()
return ToggleButton { Text = "Log in", Toggle = loggedIn }
end
}
}
end
mount(function() return create "ScreenGui" { LoginMenu {} } end, game.StarterGui)
```
Above is an example of using a switch to create a login menu. Each time
`loggedIn` toggles, the current button will be destroyed, and a new button
created, which the text to represent the current action, to log in or log out.
Another control flow function, `indexes()`, is used to create elements from an
input table.
Often, you will have a table of values that will be displayed in a similar
manner. Rather than manually looping over each value to generate a corresponding
UI element, `indexes()` can autmatically run a transform function for each
index and value, generating a UI element.
```lua
local todoList = {
"Finish the crash course",
"Star vide's GitHub"
}
local elements = indexes(todoList, function(todo, i)
return create "TextLabel" {
Text = function()
return i .. ": " .. todo()
end,
LayoutOrder = i
}
end)
mount(function()
return create "ScreenGui" {
create "UIListLayout" {}, elements
}
end, game.StarterGui)
```
For each unique index in the passed table, the transform function will be called
with 1. a source containing the value of the index, 2. the index itself.
When the value at an index is changed, the function is not reran. Instead, the
given source is updated instead.
`indexes()` is said to map each *index* in a table to a UI element, each index
has a single corresponding element.
An element is only destroyed if the value of an index is set to `nil`.

View file

@ -0,0 +1,61 @@
# Reactive Scoping
This is a brief document designed to give the user more insight into how Vide's
reactive graph works.
Each time you create and derive sources, a new node representing that source is
created and added to the reactive graph. Each node stores a value and a
side-effect function. Each node also keeps track of its parents and children,
as well as any cleanups registered.
Any time a node is updated, Vide will traverse and update that node's children,
its children's children, etc, until all nodes descending from that node has been
updated. Traversal will stop at a node if that node's cached value does not
change after an update.
For every node that is updated, a scope is opened for that node. These scopes
are referred to as "reactive scopes". Any source read from within a node's scope
will that node as a child. This is similar to cleanups, anytime a cleanup is
registered, it is added to the node of the currently active scope.
The way Vide tracks reactive scopes, is by using a stack of nodes. The current
active reactive scope is the node at the top of this stack.
When destroying a node, its descendents are traversed and also destroyed.
When being destroyed, a node's connections (parents and children) are cleared,
and any pending cleanup functions are ran.
The purpose of `root()` (which is called internally by `mount()`) is to setup
the root node which will track any node created or derived inside its scope, or
any cleanups registered. Without it, nodes could be garbage collected without a
chance to run pending cleanups which can cause memory leakage.
Control flow functions in Vide are special, as they can dynamically create and
destroy new root scopes.
It is the combination of the above which allows us to write components like so:
```lua
local function Counter()
local count = source(0)
local connection = stepped:Connect(function() count(count() + 1) end)
cleanup(function() connection:Disconnect() end)
effect(function() print(count()) end)
return create "TextLabel" { Text = count }
end
```
Vide doesn't recognise this as a "component", that is a user abstraction. Vide
just sees this as a function that creates nodes in the reactive graph.
Whenever the reactive scope that calls this function is destroyed, like by a
control flow function, the registered cleanup will be called, and the effect
(which is just a node on the reactive graph) is destroyed. The returned instance
and the bound `count` source is just considered to be a side-effect, and with
the reactive scope from which the side-effects stem from destroyed, the instance
can be garbage collected - everything is nicely cleaned up.
> todo: add graphics

View file

@ -3,7 +3,7 @@ type Action = {
callback: (Instance) -> ()
}
local ActionMT = {}
local ActionMT = table.freeze {}
local function is_action(v: any)
return getmetatable(v) == ActionMT
@ -17,7 +17,7 @@ local function action(callback: (Instance) -> (), priority: number?): Action
setmetatable(t :: any, ActionMT)
return t
return table.freeze(t)
end
return function()

View file

@ -10,11 +10,16 @@ local _, is_action = require(script.Parent.action)()
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
type Array<V> = { V }
type Map<K, V> = { [K]: V }
-- buffer of event -> callback to connect after properties are set
local event_buffer: { [string]: () -> () } = {}
local event_buffer = {} :: Map<string, () -> ()>
-- buffer of priority -> callback to run after events are connected
local action_buffers = {} :: { { (Instance) -> () } }
local action_buffers = {} :: Map<number, Array<(Instance) -> ()>>
-- lazily create buffers on nil index
setmetatable(action_buffers :: any, {
__index = function(_, i: number)
action_buffers[i] = {}
@ -22,8 +27,9 @@ setmetatable(action_buffers :: any, {
end
})
-- cache used in strict mode to detect duplicate property sets at same nesting levels
local nested_debug_cache: { [number]: { [string]: true } } = {}
-- cache in strict mode to detect duplicate property set at same nesting level
local nested_debug_cache = {} :: Map<number, Map<string, true>>
setmetatable(nested_debug_cache :: any, {
__index = function(_, i: number)
nested_debug_cache[i] = {}
@ -31,28 +37,30 @@ setmetatable(nested_debug_cache :: any, {
end
})
-- a stack used in place of a recursive function to process nesting layers one at a time
-- enforces the behavior of deeper-nested properties taking precedence of lesser-nested ones
-- each nested table occupies two indexes, reference to table itself and the depth number
-- e.g. props = { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
-- use stack instead of recursive function to process nested layers one at time
-- deeper-nested properties take precedence over shallower-nested ones
-- each nested layer occupies two indexes: 1. table ref 2. nested depth
-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
local nested_stack = {} :: { {} | number }
-- todo: solution without manual updating of this table
-- map of datatype names to class default constructor for aggregate initialization
-- map of datatype names to class default constructor for aggregate init
local aggregates = {}
for i, v in next, {
Vector2 = Vector2,
UDim2 = UDim2,
CFrame = CFrame,
Color3 = Color3,
UDim = UDim,
Rect = Rect,
Color3 = Color3
UDim2 = UDim2,
Vector2 = Vector2,
Vector3 = Vector3,
Rect = Rect
} do
aggregates[i] = v.new
end
-- processes a potentially nested table of values to assign to an instance
local function process_nested(instance: Instance, properties: { [unknown]: unknown })
local function process_props(instance: Instance, properties: Map<unknown, unknown>)
local strict = flags.strict
table.clear(nested_stack)
@ -73,27 +81,27 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno
if type(value) == "table" then -- attempt aggregate init
local ctor = aggregates[typeof((instance :: any)[property])]
if ctor == nil then
throw(`cannot aggregate construct type {typeof(value)} for property {property}`)
throw(`cannot aggregate type {typeof(value)} for property {property}`)
end
(instance :: any)[property] = ctor(unpack(value :: {}))
elseif type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> () -- add event to buffer
else
bind.property(instance, property, value :: () -> ()) -- bind source
bind.property(instance, property, value :: () -> ()) -- bind property
end
else
(instance :: any)[property] = value -- set property
end
elseif type(property) == "number" then
if type(value) == "function" then
bind.children(instance, value :: () -> { Instance }) -- bind children
bind.children(instance, value :: () -> Instance | Array<Instance>) -- bind children
elseif type(value) == "table" then
if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer
else
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
table.insert(nested_stack, value :: {})
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
end
else
(value :: Instance).Parent = instance -- parent child
@ -102,8 +110,8 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno
end
-- pop next nested table off stack
properties = table.remove(nested_stack) :: {}
depth = table.remove(nested_stack) :: number
properties = table.remove(nested_stack) :: {}
until not properties
end
@ -121,14 +129,14 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
end
-- process all properties for immediate setting or buffering
process_nested(instance, properties)
process_props(instance, properties)
-- connect buffered events
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
-- run buffered actions respecting their priorities
-- run buffered actions
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback(instance)
@ -138,7 +146,7 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
-- finally set parent if any
if parent then
if type(parent) == "function" then
error("cannot set parent to state")
bind.parent(instance, parent :: () -> Instance)
else
instance.Parent = parent :: Instance
end

View file

@ -1,152 +1,130 @@
if not game then script = require "test/relative-string" end
local warn = game and warn or print :: never
local throw = require(script.Parent.throw)
local trace = require(script.Parent.trace)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local set_effect = graph.set_effect
local capture = graph.capture
local create_node = graph.create_node
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
--[[
Roblox instances in Luau are referenced using a kind of userdata proxy,
this proxy can be garbage collected independently from the actual instance, even
if the instance is still parented. Since reactive bindings allow the garbage
collection of instances, this proxy can can garbage collected while the instance
is still parented, causing the binding to be lost and no longer update the
instance on changes.
Vide's solution to this is to hold the proxy in memory as long as the instance
is parented to the datamodel by using `GetPropertyChanged("Parent")` to add or
remove the proxy from a table whose sole purpose is to strongly reference
proxies.
todo: investigate behavior in case B is parented to A, and A has no parent or reference, and B has a binding.
]]
-- holds parented instance proxies in memory
local hold: { Instance? } = {}
-- weakly references instances with properties bound
local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any
-- unique binding id
local bind_count = 0
-- todo: replace with throw's method
local root do
local src = debug.info(1, "s")
root = string.sub(src, 1, #src - 5)
end
local function traceback(skips: number) -- ensures trace begins outside of any vide library file
local s = 1
repeat
s += 1
local path = debug.info(s, "s")
local found = not string.find(path, root)
if found then
skips -= 1
end
until found and skips < 0
return debug.traceback(nil, s)
end
function bind(instance: Instance, property: string, setter: (Instance) -> ())
function create_binding<T>(updater: (T) -> T, binding: T)
if flags.strict then
-- wrap setter in function with stack inspection for better error msgs
local fn = setter
local bind_trace = traceback(0)
setter = function(instance)
local ok, err: string? = xpcall(fn, function(err: string)
return err .. "\nsource updated at: " .. traceback(2)
end, instance)
if not ok then warn(`error occured updating {property}: {err}bound at: {bind_trace}`) end
-- track bind creation trace
local fn = updater
local bind_trace = debug.traceback(nil, trace()-1)
updater = function(...)
local ok, result = xpcall(fn, function(err: string)
return err
end, ...)
if not ok then
local btype =
if (binding :: any).property then (binding :: any).property
elseif (binding :: any).parent then "Parent"
else "children"
error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0)
end
return result
end
end
-- run setter to capture any nodes being depended on
local nodes = (capture(setter :: () -> unknown, instance))
-- register the setter as a side-effect of each node
for _, node in next, nodes do
set_effect(node, setter, instance)
end
-- get binding id
bind_count += 1
local bind_id = bind_count
-- store reference of instance proxy without preventing gc
weak[bind_id] = instance
local owner = get_scope()
if not owner then
throw("cannot bind property in non-reactive scope")
end; assert(owner)
local node = create_node(binding, updater)
local function ref()
local _ = setter -- prevent gc of nodes being depended on
local instance = weak[bind_id] :: Instance
set_owner(node, owner)
evaluate_node(node)
end
-- keep proxy in memory if instance is still parented
hold[bind_id] = instance.Parent and instance or nil
type PropertyBinding = {
instance: Instance,
property: string,
source: () -> unknown
}
local function update_property(p: PropertyBinding)
(p.instance :: any)[p.property] = p.source()
return p
end
type ParentBinding = {
instance: Instance,
parent: () -> Instance
}
local function update_parent(p: ParentBinding)
p.instance.Parent = p.parent()
return p
end
type ChildrenBinding = {
instance: Instance,
cur_children_set: { [Instance]: true },
new_children_set: { [Instance]: true },
children: () -> Instance | { Instance }
}
local function update_children(p: ChildrenBinding)
local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update
local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update
local new_children = p.children() -- all (and only) children that should be parented after this update
if type(new_children) ~= "table" then
new_children = { new_children }
end
ref()
instance:GetPropertyChangedSignal("Parent"):Connect(ref)
end
local function bind_property(instance: Instance, property: string, fn: () -> unknown)
bind(instance, property, function(instance_weak: any)
instance_weak[property] = fn()
end)
end
local function bind_parent(instance: Instance, fn: () -> Instance?)
instance.Destroying:Connect(function()
instance = nil :: any -- allow gc when destroyed
end)
bind(instance, "Parent", function(instance)
local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = fn()
end)
end
local function bind_children(parent: Instance, fn: () -> { Instance })
local current_child_set: { [Instance]: true } = {} -- cache of all children parented before update
local new_child_set: { [Instance]: true } = {} -- cache of all children parented after update
bind(parent, "Children", function(parent_weak)
local new_childs = fn() -- all (and only) children that should be parented after this update
if new_childs and type(new_childs) ~= "table" then
throw(`Cannot parent instance of type { type(new_childs) } `)
end
if new_childs then
for _, child in next, new_childs do
new_child_set[child] = true -- record child set from this update
if not current_child_set[child] then
child.Parent = parent_weak -- if child wasn't already parented then parent it
else
current_child_set[child] = nil -- remove child from cache if it was already in cache
end
if new_children then
for _, child in next, new_children :: { Instance } do
new_child_set[child] = true -- record child set from this update
if not cur_children_set[child] then
child.Parent = p.instance -- if child wasn't already parented then parent it
else
cur_children_set[child] = nil -- remove child from cache if it was already in cache
end
end
end
for child in next, current_child_set do
child.Parent = nil -- unparent all children that weren't in the new children set
end
for child in next, cur_children_set do
child.Parent = nil -- unparent all children that weren't in the new children set
end
table.clear(current_child_set) -- clear cache, preserve capacity
current_child_set, new_child_set = new_child_set, current_child_set
end)
table.clear(cur_children_set) -- clear cache, preserve capacity
p.cur_children_set, p.new_children_set = new_child_set, cur_children_set
return p
end
return {
property = bind_property,
parent = bind_parent,
children = bind_children,
property = function(instance, property, source)
return create_binding(update_property, {
instance = instance,
property = property,
source = source
})
end,
parent = function(instance, parent)
return create_binding(update_parent, {
instance = instance,
parent = parent
})
end,
children = function(instance, children)
return create_binding(update_children, {
instance = instance,
cur_children_set = {},
new_children_set = {},
children = children
})
end
}

18
src/changed.luau Normal file
View file

@ -0,0 +1,18 @@
if not game then script = require "test/relative-string" end
local action = require(script.Parent.action)()
local cleanup = require(script.Parent.cleanup)
local function changed<T>(property: string, callback: (T) -> ())
return action(function(instance)
local con = instance:GetPropertyChangedSignal(property):Connect(function()
callback((instance :: any)[property])
end)
cleanup(function()
con:Disconnect()
end)
end)
end
return changed

View file

@ -1,129 +1,18 @@
if not game then script = require "test/relative-string" end
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup
--[[
Cleanups associate a callback with an arbitrary value with an unknown lifetime.
Anytime a new callback is registered with a value that already has one registered,
the registered callback is ran and then replaced with the new one.
When the value is eventually garbage collected, Vide checks for callbacks
without an associated value, which it will then run and clear, recycling its
cleanup id.
By default the arbitrary value is the function object that calls `cleanup()`.
There are exceptions such as with `indexes()` and `values()` where the
arbitrary value is manually set to be the new source created instead of the
caller, as the same caller can be used to create multiple new objects.
todo: remove need for ref to id maps?
]]
-- maps a ref to cleanup id
local ref_to_id = {} :: { [string]: number }
-- maps a cleanup id to a ref
local id_to_ref = {} :: { [number]: string }
-- array of all cleanup callbacks
local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense
-- weak array of all cleanup lifetimes
local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse
setmetatable(cleanup_lifetime :: any, { __mode = "v" })
-- detects in strict mode when multiple cleanups are registered in the same scope
local debug_caller_to_line = {} :: { [() -> ()]: number }
setmetatable(debug_caller_to_line, { __mode = "k" })
-- when active, cleanup callbacks are not automatically registered but are
-- added to an array for manual registering internally
local manual_mode = {
caller = false :: false | () -> (),
callbacks = {} :: { () -> () }
}
-- todo: rare case where mem address is reused by another function
-- does this case handle itself?
-- registers a callback with the given lifetime using the given ref
local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
local id = ref_to_id[ref]
if id then -- invoke previously registered callback then register new one
cleanup_callbacks[id]()
cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil
else -- no previously registered callback, add and register new one
id = #cleanup_callbacks + 1
ref_to_id[ref] = id
id_to_ref[id :: any] = ref -- todo
cleanup_lifetime[id :: any] = lifetime -- todo
end
cleanup_callbacks[id] = callback
end
-- registers a callback with its caller as the lifetime, and caller address as the ref
local function cleanup(callback: () -> ())
local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup`
local scope = get_scope()
if not scope then
throw("cannot cleanup in a non-reactive scope")
end; assert(scope)
if flags.strict then
local line = debug.info(2, "l")
local cur_line = debug_caller_to_line[lifetime]
if cur_line and cur_line ~= line then
throw "only one cleanup call is allowed per function scope"
end
debug_caller_to_line[lifetime] = line
end
if manual_mode.caller == lifetime then
table.insert(manual_mode.callbacks, callback)
else
local ref = tostring(lifetime)
cleanup_ref(ref, lifetime, callback)
end
add_cleanup(scope, callback)
end
local function clean_garbage()
for id = #cleanup_callbacks, 1, -1 do
if cleanup_lifetime[id] == nil then -- lifetime was garbage collected
local callback = cleanup_callbacks[id]
return cleanup
do -- swap and pop
local max_id = #cleanup_callbacks
cleanup_callbacks[id] = cleanup_callbacks[max_id]
cleanup_callbacks[max_id] = nil
cleanup_lifetime[id] = cleanup_lifetime[max_id]
cleanup_lifetime[max_id] = nil
local ref = id_to_ref[id]
local max_ref = id_to_ref[max_id]
id_to_ref[id] = max_ref
id_to_ref[max_id] = nil
ref_to_id[max_ref] = id
ref_to_id[ref] = nil
end
local ok, err: string? = pcall(callback)
if not ok then warn(`error occured during cleanup: {err}`) end
end
end
end
local manual_cleanup_mode = function(caller: () -> ()?)
if caller == nil then
local clone = table.clone(manual_mode.callbacks)
manual_mode.caller = false
table.clear(manual_mode.callbacks)
return clone
else
manual_mode.caller = caller
end
return manual_mode.callbacks
end :: ( (caller: (...any) -> ()) -> () ) & ( (nil) -> { () -> () } )
return function() return cleanup, clean_garbage, manual_cleanup_mode, cleanup_ref end

View file

@ -7,11 +7,11 @@ local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply)
local memoize = require(script.Parent.memoize)
local function create_instance(class_name: string)
local ok, instance: Instance = pcall(Instance.new, class_name :: any)
if not ok then throw(`invalid class name, could not create instance of class { class_name }`) end
local function create_instance(class: string)
local ok, instance: Instance = pcall(Instance.new, class :: any)
if not ok then throw(`invalid class name, could not create instance of class { class }`) end
local default: { [string]: unknown }? = defaults[class_name]
local default: { [string]: unknown }? = defaults[class]
if default then
for i, v in next, default do
(instance :: any)[i] = v

View file

@ -1,10 +1,11 @@
local Enum = game and Enum or require "test/mock".Enum :: never
local Color3 = game and Color3 or require "test/mock".Color3 :: never
local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never
return {
Part = {
Material = Enum.Material.SmoothPlastic,
--Size = Vector3.new(1, 1, 1),
Size = Vector3.new(1, 1, 1),
Anchored = true
},

View file

@ -1,15 +1,28 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local create = graph.create
local capture_and_link = graph.capture_and_link
local create_node = graph.create_node
local set_owner = graph.set_owner
local track = graph.track
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local function derive<T>(fn: () -> T): () -> T
local node, read_node_value = create((false :: any) :: T)
local function derive<T>(source: () -> T): () -> T
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
node.cache = capture_and_link(node, fn)
local node = create_node(false :: any, source)
return read_node_value
set_owner(node, owner)
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
return derive

22
src/effect.luau Normal file
View file

@ -0,0 +1,22 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local create_node = graph.create_node
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local function effect<T>(callback: (T) -> T, initial_value: T)
local owner = get_scope()
if not owner then
throw("cannot create effect in non-reactive scope")
end; assert(owner)
local node = create_node(initial_value, callback)
set_owner(node, owner)
evaluate_node(node)
end
return effect :: (<T>(callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ())

View file

@ -3,25 +3,24 @@ if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
export type Node<T> = {
export type StartNode<T> = {
cache: T,
derive: () -> T,
effects: { [(unknown) -> ()]: unknown }, -- weak values
children: { Node<T> } | false -- weak values
[number]: Node<T>
}
-- flag used to detect when node reference capturing is active
local reff = false
-- array of all nodes referenced since above flag was set
local refs = {} :: { Node<unknown> }
export type Node<T> = {
cache: T,
effect: ((T) -> T) | false,
cleanups: { () -> () } | false,
parents: { owner: StartNode<T>?, [number]: StartNode<T> },
[number]: Node<T>
}
local WEAK_VALUES = { __mode = "v" }
local EVALUATION_ERR = "error while evaluating source:\n\n"
setmetatable(refs :: any, WEAK_VALUES)
-- reactive scope stack
local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
-- runs a given callback in a context that Luau does not allow yielding in
local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
local check_for_yield: <T...>(fn: (T...) -> (), T...) -> (boolean, string?) do
local t = { __mode = "kv" }
setmetatable(t, t)
@ -32,149 +31,197 @@ local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
fn(unpack(args))
end
local ok, err = pcall(function()
local ok, err: string? = pcall(function()
local _ = -t
end)
if not ok then
if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then
throw(EVALUATION_ERR .. "cannot yield when deriving node in watcher")
else
throw(EVALUATION_ERR .. err)
end
end
return ok, if err == "attempt to yield across metamethod/C-call boundary"
or err == "thread is not yieldable" then "yield occured"
else err
end
end
--[[
Each node side-effect is registered with a corresponding weak key.
This makes the lifetime of the side-effect tied to the key's.
The main usecase of this is to tie a side-effect to an instance, while allowing
the instance to be garbage collected even when the node still exists.
The weak key is passed as an argument to its side-effect callback.
]]
local function set_effect<T>(node: Node<unknown>, fn: (T) -> (), key: T)
node.effects[fn :: () -> ()] = key
local function get_scope(): Node<unknown>?
return scopes[scopes.n]
end
local function run_effects(node: Node<unknown>)
if flags.strict then -- run effects twice if strict
for effect, key in next, node.effects do
effect(key)
effect(key)
end
local function add_child<T>(parent: StartNode<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.parents.owner = owner
table.insert(owner, node)
end
local function open_scope<T>(node: Node<T>)
local n = scopes.n + 1
scopes.n = n
scopes[n] = node
end
local function close_scope()
local n = scopes.n
scopes.n = n - 1
scopes[n] = nil
end
local function add_cleanup<T>(node: Node<T>, cleanup: () -> ())
if node.cleanups then
table.insert(node.cleanups, cleanup)
else
for effect, key in next, node.effects do
effect(key)
node.cleanups = { cleanup }
end
end
local function run_cleanups<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 remove_child<T>(parent: StartNode<T>, child: Node<T>)
local idx = table.find(parent, child)
assert(idx, "child not found")
local n = #parent
parent[idx] = parent[n]
parent[n] = nil
end
local function unparent<T>(node: Node<T>)
local parents = node.parents
for i, parent in ipairs(parents) do
remove_child(parent, node)
parents[i] = nil
end
end
local function destroy<T>(node: Node<T>)
run_cleanups(node)
unparent(node)
node.effect = false
if node.parents.owner then
remove_child(node.parents.owner, node)
node.parents.owner = nil
end
while node[1] do destroy(node[1]) end
end
local update_queue = {} :: { Node<any> }
local function evaluate_node<T>(node: Node<T>)
local cur_value = node.cache
if flags.strict then
run_cleanups(node)
open_scope(node)
local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value)
close_scope()
if not ok then throw(err :: string) end
end
run_cleanups(node) -- todo: move in scope?
open_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, cur_value)
close_scope()
if not ok then
table.clear(update_queue)
throw(`side-effect error from source update\n{new_value}`)
end
node.cache = new_value
return cur_value ~= new_value -- node has changed value
end
local function update_from<T>(node: StartNode<T>, n0: number)
if not node[1] then return end
local n = n0
-- unparent all children and queue for eval
do
local child = node[1]
while child do -- todo: case where child in owner context
unparent(child)
n += 1
update_queue[n] = child
child = node[1]
end
end
end
-- retrieves a node's cached value
-- add self to refs if ref capture flag is enabled
local function get<T>(node: Node<T>): T
if reff then table.insert(refs, node) end
return node.cache
end
-- evaluate all queued children
for i = n0 + 1, n do
local child = update_queue[i]
if not child.effect then continue end
-- links two nodes as parent-child
local function set_child(parent: Node<unknown>, child: Node<unknown>)
if parent.children then
table.insert(parent.children, child)
else
parent.children = { child }
setmetatable(parent.children :: any, WEAK_VALUES)
end
end
-- runs node effects, recalculates descendants and runs descendant effects
local function update(node: Node<unknown>)
run_effects(node)
if node.children then
local strict = flags.strict
for _, child in node.children do
if strict then check_for_yield(child.derive) end
child.cache = child.derive()
update(child)
if evaluate_node(child) then
update_from(child, n)
end
update_queue[i] = false :: any -- false instead of nil to avoid sparse
end
end
-- sets a node's cached value and updates all descendants
local function set<T>(node: Node<T>, value: T)
node.cache = value
update(node)
local function update<T>(node: StartNode<T>)
update_from(node, 0)
end
-- links two nodes as parent-child with a function to compute a new value for child
local function link<T>(parent: Node<unknown>, child: Node<T>, derive: () -> T)
child.derive = derive
set_child(parent, child)
end
-- detect what nodes were referenced in the given callback and returns them in an array
local function capture<T, U>(fn: (U?) -> T, arg: U?): ({ Node<unknown> }, T)
if reff then throw("recursive capture detected") end
if flags.strict then check_for_yield(fn, arg) end
table.clear(refs)
reff = true
local ok: boolean, result: T|string
if arg == nil then
ok, result = pcall(fn)
else
ok, result = pcall(fn, arg)
local function track<T>(node: StartNode<T>)
local scope = get_scope()
if scope and scope.effect then -- do not track nodes with no effect
add_child(node, scope)
end
reff = false
if not ok then throw(EVALUATION_ERR .. result :: string) end
return refs, result :: T
end
-- captures and links any detected nodes
local function capture_and_link<T>(child: Node<T>, fn: () -> T): T
local nodes, value = capture(fn, nil)
child.derive = fn
for _, parent: Node<unknown> in next, nodes do
set_child(parent, child)
end
return value :: T
end
local function create<T>(value: T): (Node<T>, () -> T)
local node = {
local function create_node<T>(value: T, effect: false | (T) -> T): Node<T>
return {
cache = value,
derive = function() return nil :: any end,
effects = setmetatable({}, WEAK_VALUES) :: any,
children = false :: false
effect = effect,
cleanups = false,
parents = {},
}
end
local function read_node_value()
return get(node)
end
local function create_start_node<T>(value: T): StartNode<T>
return { cache = value }
end
return node, read_node_value
local function get_children<T>(node: Node<T>): { Node<unknown> }
return { unpack(node) } :: { Node<any> }
end
return table.freeze {
set_effect = set_effect,
get = get,
set = set,
link = link,
capture = capture,
capture_and_link = capture_and_link,
create = create :: (<T>(value: T) -> (Node<T>, () -> T)) & (<T>() -> (Node<T>, () -> T)),
refs = refs
open_scope = open_scope,
close_scope = close_scope,
evaluate_node = evaluate_node,
get_scope = get_scope,
add_cleanup = add_cleanup,
set_owner = set_owner,
destroy = destroy,
run_cleanups = run_cleanups,
track = track,
update = update,
add_child = add_child,
create_node = create_node,
create_start_node = create_start_node,
get_children = get_children,
scopes = scopes
}

View file

@ -5,16 +5,20 @@
if not game then script = require "test/relative-string" end
local root = require(script.root)
local mount = require(script.mount)
local create = require(script.create)
local apply = require(script.apply)
local source = require(script.source)
local watch = require(script.watch)
local cleanup, clean_garbage = require(script.cleanup)()
local effect = require(script.effect)
local cleanup = require(script.cleanup)
local untrack = require(script.untrack)
local derive = require(script.derive)
local switch = require(script.switch)
local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)()
local action = require(script.action)()
local changed = require(script.changed)
local throw = require(script.throw)
local flags = require(script.flags)
@ -28,13 +32,6 @@ local function step(dt: number)
update_springs(dt)
if game then
debug.profileend()
debug.profilebegin("VIDE GARBAGE CLEANUP")
end
clean_garbage()
if game then
debug.profileend()
debug.profileend()
@ -47,10 +44,13 @@ end)
local vide = {
-- core
root = root,
mount = mount,
create = create,
source = source,
watch = watch,
effect = effect,
derive = derive,
switch = switch,
indexes = indexes,
values = values,
@ -63,6 +63,7 @@ local vide = {
-- actions
action = action,
changed = changed,
-- flags
strict = (nil :: any) :: boolean,

View file

@ -1,16 +1,20 @@
if not game then script = require "test/relative-string" end
-- todo: more testing needed regarding `cleanup()` usage
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
local _, _, manual_cleanup_mode, cleanup_ref = require(script.Parent.cleanup)()
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local capture = graph.capture
local link = graph.link
type StartNode<T> = graph.StartNode<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 get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local evaluate_node = graph.evaluate_node
local destroy = graph.destroy
type Map<K, V> = { [K]: V }
@ -23,17 +27,22 @@ local function check_primitives(t: {})
end
end
-- todo: optimize output array
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
local subowner = create_node(false, false)
set_owner(subowner, owner)
local input_cache = {} :: Map<K, VI>
local output_cache = {} :: Map<K, VO>
local input_nodes = {} :: Map<K, Node<VI>>
local input_nodes = {} :: Map<K, StartNode<VI>>
local remove_queue = {} :: { K }
local output_array = {} :: { VO }
local scopes = {} :: Map<K, Node<unknown>>
local cleanups = {} :: Map<K, { () -> () }>
local function recompute(data)
local function update_children(data)
-- queue removed values
for i in next, input_cache do
if data[i] == nil then
@ -43,41 +52,58 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
-- remove queued values
for _, i in next, remove_queue do
for _, callback in next, cleanups[i] do
callback() -- todo: pcall
end
destroy(scopes[i])
input_cache[i] = nil
output_cache[i] = nil
input_nodes[i] = nil
cleanups[i] = nil
scopes[i] = nil
end
table.clear(remove_queue)
open_scope(subowner)
-- process new or changed values
for i, v in next, data do
local cv = input_cache[i]
if cv ~= v then
if cv == nil then
manual_cleanup_mode(transform)
if cv == nil then -- create new scope and run transform
local scope = create_node(false, false)
scopes[i] = scope :: Node<any>
local node, get_value = create(v)
local node = create_start_node(v)
set_owner(scope, subowner)
open_scope(scope)
local ok, result = pcall(transform, function()
track(node)
return node.cache
end, i)
close_scope()
if not ok then
close_scope() -- subowner scope
error(result, 0)
end
input_nodes[i] = node
output_cache[i] = transform(get_value, i)
input_cache[i] = v
cleanups[i] = manual_cleanup_mode(nil)
else
set(input_nodes[i], v)
input_cache[i] = v
output_cache[i] = result
else -- update source
input_nodes[i].cache = v
update(input_nodes[i])
end
input_cache[i] = v
end
end
-- output elements
table.clear(output_array)
close_scope()
local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
@ -86,43 +112,34 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
return output_array
end
local output, read_output_value = create(nil :: any)
local function derive()
return recompute(input())
end
local nodes, value = capture(input)
for _, node in next, nodes do
link(node, output, derive)
end
output.cache = recompute(value)
cleanup_ref(tostring(output), output, function()
for _, callbacks in next, cleanups do
for _, callback in next, callbacks do
callback() -- todo: pcall
end
end
local node = create_node(false :: any, function()
return update_children(input())
end)
return read_output_value
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
-- todo: optimize output array
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
local subowner = create_node(false, false)
set_owner(subowner, owner)
local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K>
local output_cache = {} :: Map<VI, VO>
local input_nodes = {} :: Map<VI, Node<K>>
local output_array = {} :: { VO }
local input_nodes = {} :: Map<VI, StartNode<K>>
local scopes = {} :: Map<VI, Node<unknown>>
local cleanups = {} :: Map<VI, { () -> () }>
local function recompute(data: Map<K, VI>)
local function update_children(data: Map<K, VI>)
local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up
if flags.strict then
@ -134,6 +151,8 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
cache[v] = true
end
end
open_scope(subowner)
-- process data
for i, v in next, data do
@ -141,71 +160,73 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local cv = cur_input_cache[v]
if cv == nil then
manual_cleanup_mode(transform)
if cv == nil then -- create new scope and run transform
local scope = create_node(false, false)
scopes[v] = scope :: Node<any>
local node, get_value = create(i)
input_nodes[v] = node
output_cache[v] = transform(v, get_value)
local node = create_start_node(i)
set_owner(scope, subowner)
open_scope(scope)
local ok, result = pcall(transform, v, function()
track(node)
return node.cache
end)
close_scope()
cleanups[v] = manual_cleanup_mode(nil)
else
if cv ~= i then
set(input_nodes[v], i)
if not ok then
close_scope() -- subowner scope
error(result, 0)
end
input_nodes[v] = node
output_cache[v] = result
else -- update source
if cv ~= i then
input_nodes[v].cache = i
update(input_nodes[v])
end
cur_input_cache[v] = nil
end
end
close_scope()
-- remove old values
for v in next, cur_input_cache do
for _, callback in next, cleanups[v] do
callback() -- todo: pcall
end
destroy(scopes[v])
output_cache[v] = nil
input_nodes[v] = nil
cleanups[v] = nil
scopes[v] = nil
end
-- update buffer cache
table.clear(cur_input_cache)
cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache
-- output elements
table.clear(output_array)
local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
check_primitives(output_array)
return output_array
end
local output, read_output_value = create(nil :: any)
local function derive()
return recompute(input())
end
local nodes, value = capture(input)
for _, node in next, nodes do
link(node, output, derive)
end
check_primitives(output_array)
output.cache = recompute(value)
cleanup_ref(tostring(output), output, function()
for _, callbacks in next, cleanups do
for _, callback in next, callbacks do
callback() -- todo: pcall
end
end
local node = create_node(false :: any, function()
return update_children(input())
end)
return read_output_value
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
return function() return indexes, values end

14
src/mount.luau Normal file
View file

@ -0,0 +1,14 @@
if not game then script = require "test/relative-string" end
local root = require(script.Parent.root)
local apply = require(script.Parent.apply)
local function mount<T>(component: () -> T, target: Instance?): () -> ()
return root(function(destroy)
local result = component()
if target then apply(target, { result }) end
return destroy
end)
end
return mount :: (<T>(component: () -> T, target: Instance) -> () -> ()) & ((component: () -> ()) -> () -> ())

38
src/root.luau Normal file
View file

@ -0,0 +1,38 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create_node = graph.create_node
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local destroy = graph.destroy
local refs = {}
local function root<T...>(fn: (destroy: () -> ()) -> T...): T...
local node = create_node(false, false)
refs[node] = true -- prevent gc of root node
local destroy = function()
if not refs[node] then throw "root already destroyed" end
refs[node] = nil
destroy(node)
end
open_scope(node)
local result = { pcall(fn, destroy) }
close_scope()
if not result[1] then
refs[node] = nil
throw(`mount error\n{result}`)
end
return unpack(result :: any, 2)
end
return root :: (<T...>(fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ())

View file

@ -2,23 +2,30 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local create_start_node = graph.create_start_node
local track = graph.track
local update = graph.update
export type Source<T> = (() -> T) & ((T) -> T)
local function source<T>(value: T): Source<T>
local node, read_node_value = create(value :: T)
local function source<T>(initial_value: T): Source<T>
local node = create_start_node(initial_value)
return function(...): T
if select("#", ...) == 0 then return read_node_value() end -- check if any args were given
if select("#", ...) == 0 then -- no args were given
track(node)
return node.cache
end
local v = ... :: T
if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then return v end
if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then
return v
end
set(node, v)
node.cache = v
update(node)
return v
end
end
return source :: (<T>(value: T) -> Source<T>) & (<T>() -> Source<T>)
return source :: (<T>(initial_value: T) -> Source<T>) & (<T>() -> Source<T>)

View file

@ -24,10 +24,14 @@ Unsupported datatypes:
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local set_effect = graph.set_effect
local capture = graph.capture
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local create_start_node = graph.create_start_node
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local update = graph.update
local set_owner = graph.set_owner
local track = graph.track
local UPDATE_RATE = 120
local TOLERANCE = 0.0001
@ -38,6 +42,8 @@ local function Vec3(x: number?, y: number?, z: number?)
return Vector3.new(x, y, z)
end
local ZERO = Vec3(0, 0, 0)
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData<T> = {
@ -62,21 +68,20 @@ type Vec6ToType<T> = (Vec3, Vec3) -> T
local type_to_vec6 = {
number = function(v)
return Vec3(v, 0, 0), Vec3()
return Vec3(v, 0, 0), ZERO
end :: TypeToVec6<number>,
CFrame = function(v)
-- todo: proper rotation tween
return v.Position, Vec3(v:ToEulerAnglesXYZ())
end :: TypeToVec6<CFrame>,
Color3 = function(v)
-- todo: hsv
return Vec3(v.R, v.G, v.B), Vec3()
-- todo: hsv, oklab?
return Vec3(v.R, v.G, v.B), ZERO
end :: TypeToVec6<Color3>,
UDim = function(v)
return Vec3(v.Scale, v.Offset, 0), Vec3()
return Vec3(v.Scale, v.Offset, 0), ZERO
end :: TypeToVec6<UDim>,
UDim2 = function(v)
@ -84,11 +89,11 @@ local type_to_vec6 = {
end :: TypeToVec6<UDim2>,
Vector2 = function(v)
return Vec3(v.X, v.Y, 0), Vec3()
return Vec3(v.X, v.Y, 0), ZERO
end :: TypeToVec6<Vector2>,
Vector3 = function(v)
return v, Vec3()
return v, ZERO
end :: TypeToVec6<Vector3>,
Rect = function(v)
@ -141,19 +146,16 @@ setmetatable(vec6_to_type, invalid_type)
-- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node
local springs: { [SpringData<any>]: Node<any> } = {}
local springs: { [SpringData<any>]: StartNode<any> } = {}
setmetatable(springs, { __mode = "v" })
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T
local inputs, initial_value = capture(source)
local output, output_get = create(initial_value)
local vtype = typeof(initial_value)
local x1_123, x1_456 = type_to_vec6[vtype](initial_value)
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
-- https://en.wikipedia.org/wiki/Damping
-- todo: calculate damped freq at 10tau instead of natural freq
local w_n = 2*math.pi / (period or 1)
local z = damping_ratio or 1
@ -166,34 +168,42 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
k = k,
c = c,
x0_123 = x1_123,
x1_123 = x1_123,
v_123 = Vec3(),
x0_123 = ZERO,
x1_123 = ZERO,
v_123 = ZERO,
x0_456 = x1_456,
x1_456 = x1_456,
v_456 = Vec3(),
x0_456 = ZERO,
x1_456 = ZERO,
v_456 = ZERO,
source_value = initial_value,
source_value = false :: any,
}
local output = create_start_node(false :: any)
-- reschedule spring for simulation on input update
local function input_updated(node)
local v = source()
data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v)
data.source_value = v
springs[data] = node -- todo: investigate why insertion is not O(1) at ~20k springs
local function updater_effect()
local value = source()
data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value)
data.source_value = value
springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs
return value
end
-- unused field, use so output prevents gc of inputs
output.derive = source :: any
local updater = create_node(false :: any, updater_effect)
-- register above function as side-effect for all inputs
for _, input in next, inputs do
set_effect(input, input_updated, output)
set_owner(updater, owner)
evaluate_node(updater)
-- set initial position to goal
data.x0_123, data.x0_456 = data.x1_123, data.x1_456
-- set output to goal
output.cache = data.source_value
return function()
track(output)
return output.cache
end
return output_get, data
end
local function step_springs(dt: number)
@ -251,10 +261,12 @@ local function update_spring_sources()
if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then
-- close enough to target, unshedule spring and set value to target
table.insert(remove_queue, data)
set(output, data.source_value)
output.cache = data.source_value
else
set(output, vec6_to_type[typeof(data.source_value)](x0_123, x0_456))
output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456)
end
update(output)
end
for _, data in next, remove_queue do

71
src/switch.luau Normal file
View file

@ -0,0 +1,71 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local track = graph.track
local destroy = graph.destroy
local get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
type Map<K, V> = { [K]: V }
local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U?
return function(map)
local owner = get_scope()
if not owner then
throw("cannot switch in non-reactive scope")
end; assert(owner)
local last_scope: Node<false>?
local last_component: (() -> U)?
local function update(cached): U?
local component = map[source()]
if component == last_component then return cached end
last_component = component
if last_scope then
destroy(last_scope :: Node<any>)
last_scope = nil
end
if component == nil then return nil end
if type(component) ~= "function" then
throw("map must map a value to a function")
end
local new_scope = create_node(false, false)
last_scope = new_scope :: Node<any>
set_owner(new_scope, owner)
open_scope(new_scope)
local ok, result = pcall(component)
close_scope()
if not ok then error(result, 0) end
return result
end
local node = create_node(nil :: any, update)
set_owner(node, owner)
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
end
return switch

View file

@ -1,32 +1,9 @@
-- returns path to file as an array with each directory
-- accounts for Roblox and Luau contexts
local function get_path(s)
if string.sub(s, #s - 4, #s) == ".luau" then
s = string.sub(s, 1, #s - 5)
end
if not game then script = require "test/relative-string" end
return string.split(s, string.match(s, "%w+/") and "/" or ".")
end
local trace = require(script.Parent.trace)
-- get directory of vide root
local root do
local path = get_path(debug.info(1, "s"))
root = path[#path - 1]
end
-- throws an error, ensuring stack trace begins at the first callsite outside
-- of all vide library files
local function throw(msg: string)
local stack = 1
local path = get_path(debug.info(stack, "s"))
while path[#path] == root or path[#path - 1] == root do
stack += 1
path = get_path(debug.info(stack, "s"))
end
error(msg, stack)
local function throw(msg): any
error(msg, trace()-1)
end
return throw

29
src/trace.luau Normal file
View file

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

View file

@ -1,20 +1,27 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local refs = graph.refs
local get_scope = graph.get_scope
local function untrack<T>(source: () -> T): T
local initial = #refs
local scope = get_scope()
if not scope then
throw("cannot untrack in non-reactive scope")
end; assert(scope)
local value = source()
-- sources are only tracked if the node in scope has an effect
local effect = scope.effect
scope.effect = false
-- remove any references made since `untrack()` was called
for i = initial, #refs do
refs[i] = nil
end
local ok, result = pcall(source)
return value
scope.effect = effect :: () -> ()
if not ok then error(result, 0) end
return result
end
return untrack

View file

@ -1,28 +0,0 @@
if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
local set_effect = graph.set_effect
local capture = graph.capture
local function watch(effect: () -> ()): () -> ()
local nodes = capture(effect :: () -> nil)
-- store aside captured nodes in new table
nodes = table.clone(nodes)
-- register effect with permanent lifetime
for _, node in next, nodes do
set_effect(node, effect, true)
end
local function unwatch()
-- unregister effect from all nodes
for _, node in next, nodes do
set_effect(node, effect, nil)
end
end
return unwatch
end
return watch

View file

@ -1,167 +1,291 @@
local BENCH, START = require("test/testkit").benchmark()
-- try prevent inlining by wrapping in a closure referencing an upvalue that
-- cannot be determined at compile-time
local function NO_INLINE(fn)
local r = math.random()
return function(x)
local _ = r
fn(x)
end
end
local testkit = require("test/testkit")
local BENCH, START = testkit.benchmark()
local vide = require "src/init"
local source = vide.source
local derive = vide.derive
local indexes = vide.indexes
local values = vide.values
local cleanup = vide.cleanup
local create = vide.create
local N = 2^18 -- 262144[
local function TITLE(name: string)
print()
print(testkit.color.white(name))
end
BENCH("create state", function()
local N = 2^18 -- 262144
local function WRAP_BENCH(name: string, fn: () -> ())
vide.root(function(destroy)
BENCH(name, fn)
return destroy
end)()
end
TITLE "sources"
WRAP_BENCH("create source", function()
local cache = table.create(N)
local source = vide.source
for i = 1, START(N) do
cache[i] = source(1)
end
end)
BENCH("get value", function()
local state = vide.source(1)
WRAP_BENCH("get value", function()
local src = source(1)
for i = 1, START(N) do
state()
src()
end
end)
BENCH("set value", function()
local state = vide.source(1)
WRAP_BENCH("set value", function()
local src = source(1)
for i = 1, START(N) do
state(i)
src(i)
end
end)
BENCH("derive 1 state", function()
WRAP_BENCH("derive 1 source", function()
local cache = table.create(N)
local state = vide.source(1)
local derive = vide.derive
local src = source(1)
for i = 1, START(N) do
cache[i] = derive(function()
return state()
return src()
end)
end
end)
BENCH("derive 4 states", function()
WRAP_BENCH("derive 4 sources", function()
local cache = table.create(N)
local state = vide.source(1)
local state2 = vide.source(2)
local state3 = vide.source(3)
local state4 = vide.source(4)
local derive = vide.derive
local src = vide.source(1)
local src2 = vide.source(2)
local src3 = vide.source(3)
local src4 = vide.source(4)
for i = 1, START(N) do
cache[i] = derive(function()
return state() + state2() + state3() + state4()
return src() + src2() + src3() + src4()
end)
end
end)
BENCH("set derived value", function()
local state = vide.source(1)
local _derived = vide.derive(state)
TITLE "graphs"
WRAP_BENCH("update 1->1 graph", function()
local src = source(1)
local _derived = derive(function() return src() end)
for i = 1, START(N) do
state(i)
src(i)
end
end)
BENCH("apply 0 properties", function()
WRAP_BENCH("update 1->1 graph with cleanup", function()
local src = source(1)
derive(function()
cleanup(function() end)
return src()
end)
for i = 1, START(N) do
src(i)
end
end)
WRAP_BENCH("update 1->1000 graph", function()
local src = source(-1)
for i = 1, 1000 do
derive(function() return src() end)
end
src(0)
for i = 1, START(10) do
src(i)
end
end)
WRAP_BENCH("update 1->1->1->1...1000 graph", function()
local src = source(-1)
local last = src
for i = 1, 1000 do
local l = last
last = derive(function() return l() end)
end
src(0)
for i = 1, START(10) do
src(i)
end
end)
-- todo: repeat with batching
WRAP_BENCH("update 1000->1 graph", function()
local srcs = {}
for i = 1, 1000 do
srcs[i] = source(0)
end
derive(function()
for i = 1, 1000 do
srcs[i]()
end
return false
end)
for i = 1, START(1) do
for idx = 1, 1000 do
srcs[idx](i)
end
end
end)
-- todo: optimize, repeat with batching
WRAP_BENCH("update 1000x 1->1 common extern. graph", function()
local ext = source(-1)
local srcs = {}
for i = 1, 1000 do
srcs[i] = source(0)
derive(function() return srcs[i]() + ext() end)
end
ext(0)
for i = 1, START(10) do
for idx = 1, 1000 do
srcs[idx](i)
end
end
end)
TITLE "property apply"
WRAP_BENCH("apply 0 properties", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local instance = create("Frame") {}
for i = 1, START(N) do
apply(instance, {})
end
end)
BENCH("apply 8 properties", function()
WRAP_BENCH("apply 8 properties", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local instance = create("Frame") {}
for i = 1, START(N) do
apply(instance, {
Name = i,
Name2 = i,
Name3 = i,
Name4 = i,
Name5 = i,
Name6 = i,
Name7 = i,
Name8 = i,
Text = i,
Text2 = i,
Text3 = i,
Text4 = i,
Text5 = i,
Text6 = i,
Text7 = i,
Text8 = i,
})
end
end)
BENCH("bind source", function()
WRAP_BENCH("bind property", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local state = vide.source(1)
local instance = create("Frame") {}
local src = source(1)
for i = 1, START(N) do
apply(instance, {
Name = state
Text = src
})
end
return nil
end)
BENCH("update binding", function()
WRAP_BENCH("update binding", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local state = vide.source(1)
local instance = create("Frame") {}
local src = source(1)
apply(instance, {
Name = state
Text = src
})
for i = 1, START(N) do
state(i)
src(i)
end
return nil
end)
BENCH("indexes() no change", function()
TITLE "indexes()"
N /= 1024
WRAP_BENCH("indexes() all new", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
--state(state()) -- fill double buffer
local src = source(data)
START(N)
state(data)
local _list = indexes(src, function(v, i)
return {}
end)
return nil
end)
BENCH("indexes() all change", function()
WRAP_BENCH("indexes() no change", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local src = source(data)
local _list = vide.indexes(state, function(v, i)
local _list = indexes(src, function(v, i)
return {}
end)
--state(state()) -- fill double buffer
START(N)
src(data)
return nil
end)
WRAP_BENCH("indexes() all change", function()
local data = {}
for i = 1, N do
data[i] = i
end
local src = source(data)
local _list = indexes(src, function(v, i)
return {}
end)
--src(src()) -- fill double buffer
for i, v in data do
data[i] = v + 1
@ -169,19 +293,19 @@ BENCH("indexes() all change", function()
START(N)
state(data)
src(data)
end)
BENCH("indexes() all remove", function()
WRAP_BENCH("indexes() all remove", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local src = source(data)
local _list = vide.indexes(state, function(v, i)
local _list = indexes(src, function(v, i)
return {}
end)
@ -189,43 +313,65 @@ BENCH("indexes() all remove", function()
START(N)
state(data)
src(data)
return nil
end)
BENCH("values() no change", function()
TITLE "values()"
WRAP_BENCH("values() all new", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local src = source(data)
local _list = vide.values(state, function(v, i)
START(N)
local _list = values(src, function(v, i)
return {}
end)
state(state()) -- fill double buffer
return nil
end)
WRAP_BENCH("values() no change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local src = source(data)
local _list = values(src, function(v, i)
return {}
end)
src(src()) -- fill double buffer
START(N)
state(data)
src(data)
end)
BENCH("values() all change", function()
WRAP_BENCH("values() all change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local src = source(data)
local _list = vide.values(state, function(v, i)
local _list = values(src, function(v, i)
return {}
end)
state(state()) -- fill double buffer
src(src()) -- fill double buffer
for i = 1, N do
local r = math.random(1, #data)
@ -234,19 +380,19 @@ BENCH("values() all change", function()
START(N)
state(data)
src(data)
end)
BENCH("values() all remove", function()
WRAP_BENCH("values() all remove", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local src = source(data)
local _list = vide.values(state, function(v, i)
local _list = values(src, function(v, i)
return {}
end)
@ -254,11 +400,15 @@ BENCH("values() all remove", function()
START(N)
state(data)
src(data)
end)
BENCH("register new cleanup", function()
local cleanup = vide.cleanup
N *= 1024
TITLE "cleanup"
WRAP_BENCH("register new cleanup", function()
local cleanup = cleanup
local cleaner = function() end
@ -276,61 +426,12 @@ BENCH("register new cleanup", function()
end
end)
-- cleanup cleanups from previous benchmark
;(collectgarbage :: any)("collect")
vide.step(0)
BENCH("repeat cleanup", function()
local cleanup = vide.cleanup
local foo = NO_INLINE(function(i)
cleanup(function()
-- return i -- uncomment to include overhead of closure creation
end)
end)
for i = 1, START(N) do
foo(i)
end
end)
-- cleanup cleanups from previous benchmark
;(collectgarbage :: any)("collect")
vide.step(0)
BENCH("cleanup gc check", function()
local cleanup = vide.cleanup
local cleaner = function() end
local callers = table.create(N)
for i = 1, N do
callers[i] = function()
cleanup(cleaner)
return i
end
callers[i]()
end
START(N)
vide.step(0)
end)
BENCH("cleanup gc removal", function()
START(N)
vide.step(0) -- cleanup from previous benchmark
end)
TITLE "aggregate"
do
-- the purpose of the two following benchmarks is to measure the overhead of
-- aggregate construction
BENCH("set explicit mock vector2", function()
local create = vide.create
WRAP_BENCH("set explicit mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@ -345,8 +446,7 @@ do
end
end)
BENCH("set aggregate mock vector2", function()
local create = vide.create
WRAP_BENCH("set aggregate mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@ -362,4 +462,47 @@ do
end)
end
-- innacurate due to no Vector3 in vanilla Luau
-- mock vector is 200x slower than native vector
-- WRAP_BENCH("spring update", function()
-- local root, source, spring = vide.root, vide.source, vide.spring
-- local src = source(0)
-- root(function()
-- for i = 1, N do
-- spring(src)
-- end
-- START(N)
-- src(1)
-- return nil
-- end)
-- end)
-- N /= 1024
-- WRAP_BENCH("spring step", function()
-- local root, source, spring = vide.root, vide.source, vide.spring
-- local src = source(0)
-- root(function()
-- for i = 1, N do
-- spring(src)
-- end
-- src(1)
-- START(N)
-- vide.step(1/60)
-- return nil
-- end)
-- end)
return nil

View file

@ -113,6 +113,7 @@ local Instance = {} :: any do
local function __newindex(userdata: userdata, property: string, value: unknown)
local data = get_data(userdata)
if property == "Name" then
if type(value) ~= "string" then error("name must be a string", 2) end
data.name = value :: string
elseif property == "Parent" then
assert(value == nil or is_instance(value), "attempt to set non-instance as parent")

View file

@ -1,5 +1,6 @@
local function dir(directory: string)
return setmetatable({} :: { [string]: any }, { __index = function(_, path) return directory .. path end })
return setmetatable({} :: { [string]: any },
{ __index = function(_, path) return directory .. path end })
end
local script = dir "src/"

View file

@ -37,12 +37,12 @@ local function main()
local source = vide.source
local spring = vide.spring
local watch = vide.watch
local effect = vide.effect
local value = source(MAX)
local sprung = spring(value, 1, 0.3)
watch(function()
effect(function()
local v = sprung()
local fv = math.floor(v)
local reset = "\27[H\27[2J" -- ANSI clear terminal
@ -65,7 +65,7 @@ local function main()
until false
end
main()
vide.root(main)

File diff suppressed because it is too large Load diff

24
todo.md
View file

@ -1,23 +1,13 @@
# todo
- better error reporting and stack traces in strict mode
- auto-enable of strict mode depending on compiler optimizaton level
- investigate if weak table iteration can be invalidated
- have derived sources/bindings track sources dynamically?
- solves case where sources are used in if-branching guarded by another
source
- significantly reduces performance
- solution to component cleanup
- rely on `Instance.Destroying` event and manual destruction when cleanup is
needed?
- expand behavior of `vide.cleanup()` to detect garbage collection of
arbitrary values, not needing manual destruction
- look into SolidJS's reactive contexts
- property binding optimization
- would no longer allow `cleanup()` usage in binding scopes
- solution to nested reactivity, see: SolidJS stores
- SolidJS control flow components
- Show
- Switch
- Dynamic
- investigate performance of wide graphs
- optimize child removal
- implement from solid:
- Portal
- batch source updates
- batch
- optimize `indexes()` double-diffing
- define behavior of deriving a source within a derived source