mirror of
https://github.com/centau/vide.git
synced 2026-08-20 14:41:37 +00:00
Merge reactive scope refactor
This commit is contained in:
parent
0e439f084f
commit
efc4798ddb
48 changed files with 2750 additions and 1949 deletions
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
186
docs/api/reactivity-flow.md
Normal 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.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Table Source
|
||||
# Control Flow
|
||||
|
||||
Vide has specific functions for dealing with sources that store a table value.
|
||||
|
||||
66
docs/tut/control-flow/switch.md
Normal file
66
docs/tut/control-flow/switch.md
Normal 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.
|
||||
66
docs/tut/control-flow/values.md
Normal file
66
docs/tut/control-flow/values.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
45
docs/tut/crash-course/5-effect.md
Normal file
45
docs/tut/crash-course/5-effect.md
Normal 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.
|
||||
76
docs/tut/crash-course/6-derived-source.md
Normal file
76
docs/tut/crash-course/6-derived-source.md
Normal 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.
|
||||
40
docs/tut/crash-course/7-cleanup.md
Normal file
40
docs/tut/crash-course/7-cleanup.md
Normal 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.
|
||||
95
docs/tut/crash-course/8-control-flow.md
Normal file
95
docs/tut/crash-course/8-control-flow.md
Normal 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`.
|
||||
61
docs/tut/reactive-scoping.md
Normal file
61
docs/tut/reactive-scoping.md
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
234
src/bind.luau
234
src/bind.luau
|
|
@ -1,152 +1,130 @@
|
|||
if not game then script = require "test/relative-string" end
|
||||
local warn = game and warn or print :: never
|
||||
|
||||
local throw = require(script.Parent.throw)
|
||||
local trace = require(script.Parent.trace)
|
||||
local flags = require(script.Parent.flags)
|
||||
local graph = require(script.Parent.graph)
|
||||
type Node<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
18
src/changed.luau
Normal 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
|
||||
129
src/cleanup.luau
129
src/cleanup.luau
|
|
@ -1,129 +1,18 @@
|
|||
if not game then script = require "test/relative-string" end
|
||||
|
||||
local flags = require(script.Parent.flags)
|
||||
local throw = require(script.Parent.throw)
|
||||
local graph = require(script.Parent.graph)
|
||||
local get_scope = graph.get_scope
|
||||
local add_cleanup = graph.add_cleanup
|
||||
|
||||
--[[
|
||||
|
||||
Cleanups associate a callback with an arbitrary value with an unknown lifetime.
|
||||
Anytime a new callback is registered with a value that already has one registered,
|
||||
the registered callback is ran and then replaced with the new one.
|
||||
|
||||
When the value is eventually garbage collected, Vide checks for callbacks
|
||||
without an associated value, which it will then run and clear, recycling its
|
||||
cleanup id.
|
||||
|
||||
By default the arbitrary value is the function object that calls `cleanup()`.
|
||||
There are exceptions such as with `indexes()` and `values()` where the
|
||||
arbitrary value is manually set to be the new source created instead of the
|
||||
caller, as the same caller can be used to create multiple new objects.
|
||||
|
||||
todo: remove need for ref to id maps?
|
||||
|
||||
]]
|
||||
|
||||
-- maps a ref to cleanup id
|
||||
local ref_to_id = {} :: { [string]: number }
|
||||
-- maps a cleanup id to a ref
|
||||
local id_to_ref = {} :: { [number]: string }
|
||||
-- array of all cleanup callbacks
|
||||
local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense
|
||||
-- weak array of all cleanup lifetimes
|
||||
local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse
|
||||
setmetatable(cleanup_lifetime :: any, { __mode = "v" })
|
||||
|
||||
-- detects in strict mode when multiple cleanups are registered in the same scope
|
||||
local debug_caller_to_line = {} :: { [() -> ()]: number }
|
||||
setmetatable(debug_caller_to_line, { __mode = "k" })
|
||||
|
||||
-- when active, cleanup callbacks are not automatically registered but are
|
||||
-- added to an array for manual registering internally
|
||||
local manual_mode = {
|
||||
caller = false :: false | () -> (),
|
||||
callbacks = {} :: { () -> () }
|
||||
}
|
||||
|
||||
-- todo: rare case where mem address is reused by another function
|
||||
-- does this case handle itself?
|
||||
|
||||
-- registers a callback with the given lifetime using the given ref
|
||||
local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
|
||||
local id = ref_to_id[ref]
|
||||
|
||||
if id then -- invoke previously registered callback then register new one
|
||||
cleanup_callbacks[id]()
|
||||
cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil
|
||||
else -- no previously registered callback, add and register new one
|
||||
id = #cleanup_callbacks + 1
|
||||
ref_to_id[ref] = id
|
||||
id_to_ref[id :: any] = ref -- todo
|
||||
cleanup_lifetime[id :: any] = lifetime -- todo
|
||||
end
|
||||
|
||||
cleanup_callbacks[id] = callback
|
||||
end
|
||||
|
||||
-- registers a callback with its caller as the lifetime, and caller address as the ref
|
||||
local function cleanup(callback: () -> ())
|
||||
local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup`
|
||||
local scope = get_scope()
|
||||
if not scope then
|
||||
throw("cannot cleanup in a non-reactive scope")
|
||||
end; assert(scope)
|
||||
|
||||
if flags.strict then
|
||||
local line = debug.info(2, "l")
|
||||
local cur_line = debug_caller_to_line[lifetime]
|
||||
if cur_line and cur_line ~= line then
|
||||
throw "only one cleanup call is allowed per function scope"
|
||||
end
|
||||
debug_caller_to_line[lifetime] = line
|
||||
end
|
||||
|
||||
if manual_mode.caller == lifetime then
|
||||
table.insert(manual_mode.callbacks, callback)
|
||||
else
|
||||
local ref = tostring(lifetime)
|
||||
cleanup_ref(ref, lifetime, callback)
|
||||
end
|
||||
add_cleanup(scope, callback)
|
||||
end
|
||||
|
||||
local function clean_garbage()
|
||||
for id = #cleanup_callbacks, 1, -1 do
|
||||
if cleanup_lifetime[id] == nil then -- lifetime was garbage collected
|
||||
local callback = cleanup_callbacks[id]
|
||||
return cleanup
|
||||
|
||||
do -- swap and pop
|
||||
local max_id = #cleanup_callbacks
|
||||
|
||||
cleanup_callbacks[id] = cleanup_callbacks[max_id]
|
||||
cleanup_callbacks[max_id] = nil
|
||||
|
||||
cleanup_lifetime[id] = cleanup_lifetime[max_id]
|
||||
cleanup_lifetime[max_id] = nil
|
||||
|
||||
local ref = id_to_ref[id]
|
||||
local max_ref = id_to_ref[max_id]
|
||||
|
||||
id_to_ref[id] = max_ref
|
||||
id_to_ref[max_id] = nil
|
||||
|
||||
ref_to_id[max_ref] = id
|
||||
ref_to_id[ref] = nil
|
||||
end
|
||||
|
||||
local ok, err: string? = pcall(callback)
|
||||
if not ok then warn(`error occured during cleanup: {err}`) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local manual_cleanup_mode = function(caller: () -> ()?)
|
||||
if caller == nil then
|
||||
local clone = table.clone(manual_mode.callbacks)
|
||||
manual_mode.caller = false
|
||||
table.clear(manual_mode.callbacks)
|
||||
return clone
|
||||
else
|
||||
manual_mode.caller = caller
|
||||
end
|
||||
return manual_mode.callbacks
|
||||
end :: ( (caller: (...any) -> ()) -> () ) & ( (nil) -> { () -> () } )
|
||||
|
||||
return function() return cleanup, clean_garbage, manual_cleanup_mode, cleanup_ref end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
src/effect.luau
Normal 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: () -> ()) -> ())
|
||||
305
src/graph.luau
305
src/graph.luau
|
|
@ -3,25 +3,24 @@ if not game then script = require "test/relative-string" end
|
|||
local throw = require(script.Parent.throw)
|
||||
local flags = require(script.Parent.flags)
|
||||
|
||||
export type Node<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
211
src/maps.luau
211
src/maps.luau
|
|
@ -1,16 +1,20 @@
|
|||
if not game then script = require "test/relative-string" end
|
||||
|
||||
-- todo: more testing needed regarding `cleanup()` usage
|
||||
|
||||
local throw = require(script.Parent.throw)
|
||||
local flags = require(script.Parent.flags)
|
||||
local graph = require(script.Parent.graph)
|
||||
local _, _, manual_cleanup_mode, cleanup_ref = require(script.Parent.cleanup)()
|
||||
type Node<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
14
src/mount.luau
Normal 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
38
src/root.luau
Normal 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: () -> ()) -> ()) -> ())
|
||||
|
|
@ -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>)
|
||||
|
|
|
|||
|
|
@ -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
71
src/switch.luau
Normal 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
|
||||
|
|
@ -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
29
src/trace.luau
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
1590
test/tests.luau
1590
test/tests.luau
File diff suppressed because it is too large
Load diff
24
todo.md
24
todo.md
|
|
@ -1,23 +1,13 @@
|
|||
# todo
|
||||
|
||||
- better error reporting and stack traces in strict mode
|
||||
- auto-enable of strict mode depending on compiler optimizaton level
|
||||
- investigate if weak table iteration can be invalidated
|
||||
- have derived sources/bindings track sources dynamically?
|
||||
- solves case where sources are used in if-branching guarded by another
|
||||
source
|
||||
- significantly reduces performance
|
||||
- solution to component cleanup
|
||||
- rely on `Instance.Destroying` event and manual destruction when cleanup is
|
||||
needed?
|
||||
- expand behavior of `vide.cleanup()` to detect garbage collection of
|
||||
arbitrary values, not needing manual destruction
|
||||
- look into SolidJS's reactive contexts
|
||||
- property binding optimization
|
||||
- would no longer allow `cleanup()` usage in binding scopes
|
||||
- solution to nested reactivity, see: SolidJS stores
|
||||
- SolidJS control flow components
|
||||
- Show
|
||||
- Switch
|
||||
- Dynamic
|
||||
- investigate performance of wide graphs
|
||||
- optimize child removal
|
||||
- implement from solid:
|
||||
- Portal
|
||||
- batch source updates
|
||||
- batch
|
||||
- optimize `indexes()` double-diffing
|
||||
- define behavior of deriving a source within a derived source
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue