Merge branch 'main' into fix/color

This commit is contained in:
richard 2024-04-11 04:09:37 -07:00 committed by GitHub
commit b844fd076a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 2598 additions and 1119 deletions

View file

@ -4,10 +4,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased --------------------------------------------------------------------------------
--- ## [0.2.0] - 2023-11-22
## [0.1.0] - 0000-00-00 ### Added
- Batched updates with `batch()`.
### Changed
- Improved graph updating algorithm.
- Graph nodes when destroyed no longer destroy children; only owned.
### Fixed
- Graph edge case where a destroyed node can be readded if it was queued for
rerun before being destroyed.
- Some properties not being applied when `create()` is used recursively.
--------------------------------------------------------------------------------
## [0.1.1] - 2023-09-30
### Added
- `cleanup()` accepts objects with a `Destroy()` or `Disconnect()` interface.
- `read()` as a utility to read sources or passthrough a non-source value.
### Changed
- Reactive scopes created within reactive scopes are now destroyed on rerun.
- `untrack()` can be called outside of reactive scopes.
- `changed()` will also run its callback with the initial property value.
### Fixed
- `show()` and `switch()` not updating when in strict mode.
--------------------------------------------------------------------------------
## [0.1.0] - 2023-09-20
- Initial release - Initial release

View file

@ -1,18 +1,13 @@
<br> <br>
<div align="center"> <div align="center">
<img style="float: right;margin-top:50px" src="docs/public/logo.svg" width="110" /> <img src="docs/public/full_logo.svg" width="600" />
</div> </div>
<br> Vide is a reactive Luau UI library inspired by [Solid](https://www.solidjs.com/).
### ⚠️ This library is in early stages of development with breaking changes being made often.
Vide is a reactive UI library.
- Fully Luau typecheckable - Fully Luau typecheckable
- Declarative and concise syntax. - Declarative and concise syntax.
- Minimal imports.
- Reactively driven. - Reactively driven.
## Getting started ## Getting started
@ -24,7 +19,6 @@ for a quick introduction to the library.
## Code sample ## Code sample
```lua ```lua
local vide = require(path_to_vide)
local create = vide.create local create = vide.create
local source = vide.source local source = vide.source

View file

@ -1,7 +1,8 @@
import { defineConfig } from "vitepress" //import { defineConfig } from "vitepress"
import { withMermaid } from "vitepress-plugin-mermaid";
// https://vitepress.dev/reference/site-config // https://vitepress.dev/reference/site-config
export default defineConfig({ export default withMermaid({
title: "Vide", title: "Vide",
titleTemplate: ":title - A reactive UI library for Luau", titleTemplate: ":title - A reactive UI library for Luau",
description: "A reactive UI library for Luau.", description: "A reactive UI library for Luau.",
@ -16,7 +17,6 @@ export default defineConfig({
{ text: "Home", link: "/" }, { text: "Home", link: "/" },
{ text: "Tutorials", link: "/tut/crash-course/1-introduction" }, { text: "Tutorials", link: "/tut/crash-course/1-introduction" },
{ text: "API", link: "/api/reactivity-core"}, { text: "API", link: "/api/reactivity-core"},
{ text: "GitHub", link: "https://github.com/centau/vide" }
], ],
sidebar: { sidebar: {
@ -41,34 +41,31 @@ export default defineConfig({
{ text: "Introduction", link: "/tut/crash-course/1-introduction" }, { text: "Introduction", link: "/tut/crash-course/1-introduction" },
{ text: "Element Creation", link: "/tut/crash-course/2-creation" }, { text: "Element Creation", link: "/tut/crash-course/2-creation" },
{ text: "Components", link: "/tut/crash-course/3-components" }, { text: "Components", link: "/tut/crash-course/3-components" },
{ text: "Source", link: "/tut/crash-course/4-source" }, { text: "Sources", link: "/tut/crash-course/4-source" },
{ text: "Effect", link: "/tut/crash-course/5-effect" }, { text: "Effects", link: "/tut/crash-course/5-effect" },
{ text: "Derived Source", link: "/tut/crash-course/6-derived-source" }, { text: "Root Scopes", link: "/tut/crash-course/6-root" },
{ text: "Cleanup", link: "/tut/crash-course/7-cleanup" }, { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" },
{ text: "Control Flow", link: "/tut/crash-course/8-control-flow" }, { text: "Property Binding", link: "/tut/crash-course/8-property-binding" },
{ text: "Property Nesting", link: "/tut/crash-course/9-property-nesting" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" },
{ text: "Actions", link: "/tut/crash-course/10-actions" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" },
{ text: "Control Flow", link: "/tut/crash-course/11-control-flow" },
{ text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" },
{ text: "Actions", link: "/tut/crash-course/13-actions" },
{ text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" },
{ text: "Concepts Summary", link: "/tut/crash-course/15-concepts" }
] ]
}, },
{ {
text: "Control Flow WIP", text: "Advanced Reactivity",
items: [ items: [
{ text: "switch", link: "/tut/control-flow/switch.md" }, { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.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"}
] ]
} }
], ],
} },
// socialLinks: [ socialLinks: [
// { icon: "github", link: "https://github.com/centau/vide" } { icon: "github", link: "https://github.com/centau/vide" }
// ] ]
} }
}) })

View file

@ -4,7 +4,8 @@
## mount() ## mount()
Runs a function and applies its result to a target instance. Runs a function in a new reactive scope and optionally applies its result to a
target instance.
- **Type** - **Type**
@ -14,7 +15,7 @@ Runs a function and applies its result to a target instance.
- **Details** - **Details**
The result of the function is applies to the target in the same way The result of the function is applied to a target in the same way
properties are using `create()`. properties are using `create()`.
The function is ran in a new reactive scope, just like The function is ran in a new reactive scope, just like
@ -60,19 +61,16 @@ Creates a new UI element, applying any given properties.
- **Property setting rules** - **Property setting rules**
- If a table index is a string: - **index is string:**
- If its value is a function then it will either bind that property to - **value is function:**
the function or connect it if the property type is a `RBXScriptSignal`. - **property is event:** connect function as callback
- If the value is not a function then the property will be set to that - **property is not event:** create effect to update property
value. - **value is not function:** set property to value
- If a table index is a number: - **index is number:**
- If its value is an action then that action will be queued to run after - **value is action:** run action
properties are set. - **value is table:** recurse table
- If its value is a table then that table will be recursively - **value is function:** create effect to update children
processed just like the outer table. - **value is instance:** set instance as child
- 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** - **Example**
@ -138,9 +136,14 @@ instances.
```lua ```lua
local function changed(property: string, callback: (new) -> ()) local function changed(property: string, callback: (new) -> ())
return action(function(instance) return action(function(instance)
instance:GetPropertyChangedSignal("property"):Connect(function() local con - instance:GetPropertyChangedSignal(property):Connect(function()
callback(instance[property]) callback(instance[property])
end) end)
-- disconnect on reactive scope destruction to allow gc of instance
cleanup(function()
con:Disconnect()
end)
end) end)
end end
@ -151,3 +154,23 @@ instances.
changed("Text", output) changed("Text", output)
} }
``` ```
## changed()
A wrapper for `action()` to listen for property changes.
- **Type**
```lua
function changed(property: string, callback: (...unknown) -> ()): Action
```
- **Details**
Will run the given callback any time the property is changed, as well as
when the action is initially run.
The changed connection is disconnected when the reactive scope the action is
ran in is destroyed.
Runs with an action priority of 1.

View file

@ -2,6 +2,10 @@
<br/> <br/>
:::warning
Yielding is not allowed in any reactive scope. Strict mode can check for this.
:::
## root() ## root()
Creates and runs a function in a new reactive scope. Creates and runs a function in a new reactive scope.
@ -14,18 +18,14 @@ Creates and runs a function in a new reactive scope.
- **Details** - **Details**
Returns the result of the given function.
Creates a new root reactive scope, where creation and derivations of sources Creates a new root reactive scope, where creation and derivations of sources
can be tracked and properly disposed of. 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 A function to destroy the root is passed into the callback, which will run
any cleanups and allow derived sources created to garbage collect. any cleanups and allow derived sources created to garbage collect.
::: warning
`fn()` cannot yield.
:::
## source() ## source()
Creates a new source with the given value. Creates a new source with the given value.
@ -44,6 +44,8 @@ Creates a new source with the given value.
Reading from the source from within a reactive scope will cause changes 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. to that source to be tracked and anything depending on it to update.
Sources can be created outside of reactive scopes.
- **Example** - **Example**
```lua ```lua
@ -56,7 +58,7 @@ Creates a new source with the given value.
## effect() ## effect()
Runs a side-effect on source update. Runs a side-effect in a new reactive scope on source update.
- **Type** - **Type**
@ -66,14 +68,10 @@ Runs a side-effect on source update.
- **Details** - **Details**
The callback is ran immediately.
Any time a source referenced in the callback is changed, the callback will Any time a source referenced in the callback is changed, the callback will
be reran. be reran.
::: warning The callback is ran to initially ran on first call to find dependent sources.
`callback()` cannot yield.
:::
- **Example** - **Example**
@ -93,7 +91,7 @@ Runs a side-effect on source update.
## derive() ## derive()
Derives a new source from existing sources. Derives a new source in a new reactive scope from existing sources.
- **Type** - **Type**
@ -109,12 +107,7 @@ Derives a new source from existing sources.
Anytime its value is recalculated it is also cached, subsequent calls will Anytime its value is recalculated it is also cached, subsequent calls will
retun this cached value until it recalculates again. retun this cached value until it recalculates again.
Takes a callback that is immediately run to determine what sources are being The callback is ran to initially ran on first call to find dependent sources.
referenced.
::: warning
`source()` cannot yield.
:::
- **Example** - **Example**

View file

@ -2,9 +2,33 @@
<br/> <br/>
## show()
Shows one of two components depending on an input source.
- **Type**
```lua
function show<T>(source: () -> unknown, component: () -> T): () -> T?
function show<T, U>(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U
```
- **Details**
Returns a source holding an instance of the currently shown component.
When the input source changes from a falsey to a truthy value, the
component will be reran under a new reactive scope. If it changes from a
truthy to falsey value, the reactive scope the component was created in will
be destroyed, and the returned source will output `nil`, or a fallback
component if given.
The fallback component is also ran under a new reactive scope, and destroyed
when the input source switches back to truthy.
## switch() ## switch()
Changes object based on a source and a mapping table. Shows one of a set of components depending on an input source and a mapping table.
- **Type** - **Type**
@ -14,12 +38,14 @@ Changes object based on a source and a mapping table.
- **Details** - **Details**
The mapped function is ran in a new reactive scope that is destroyed when Returns a source holding an instance of the currently shown component.
the source changes and maps to a different function.
::: warning When the input source changes, the new value will be used to lookup a given
Mapped functions cannot yield. mapping table to get a component, which will be ran under a new reactive
::: scope. If the input source changes, the reactive scope the component was
created in will be destroyed, and a new component created under a new
reactive scope. If no component is found for an input value, the switch will
output `nil`.
- **Example** - **Example**
@ -51,24 +77,26 @@ Maps each index in a table source to an object.
- **Details** - **Details**
Returns a source holding an array of instances currently shown.
When the input source changes, each *index* in the new table is compared with
the last input table.
- For any new index, the `transform` function is ran under a new reactive
scope to produce a new instance.
- For any removed index, the reactive scope for that index is destroyed.
- Unchanged indexes are untouched.
The transform function is called only ever *once* for each index in the 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 source table.
and the second argument is just the index.
Anytime a new index is added, the transform function will be called again 1. First argument is a *source containing the index's value*.
for that new index. 2. Second argument is the *index itself*.
Anytime an existing index value changes, the transform function is not rerun, Anytime an existing index's value changes, the transform function is not
instead the source value for that index will update, causing anything rerun, instead the source value for that index will update, causing anything
depending on it to update too. depending on it to update too.
Returns a state containing an array of all objects returned by the
transform.
::: warning
`transform()` cannot yield.
:::
- **Example** - **Example**
The intended purpose of this function is to map each index in a table to The intended purpose of this function is to map each index in a table to
@ -85,14 +113,12 @@ Maps each index in a table source to an object.
local displays = indexes(items, function(item, i) local displays = indexes(items, function(item, i)
return ItemDisplay { return ItemDisplay {
Name = function() Name = function()
return item().name return i .. ": " .. item().name
end, end,
Image = function() Image = function()
return "rbxassetid://" .. item().icon return "rbxassetid://" .. item().icon
end, end,
LayoutOrder = i
} }
end) end)
``` ```
@ -111,28 +137,31 @@ Maps each value in a table source to an object.
- **Details** - **Details**
The transform function is called only ever *once* for each value in the Returns a source holding an array of instances currently shown.
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 When the input source changes, each *value* in the new table is compared with
for that new value. the last input table. Similar to `indexes()` but for values instead of indexes.
- For any new value, the `transform` function is ran under a new reactive
scope to produce a new instance.
- For any removed value, the reactive scope for that value is destroyed.
- Unchanged values are untouched.
The transform function is only ever called *once* for each value in the
source table.
1. First argument is the *value itself*.
2. Second argument is a *source containing the value's index*.
Anytime an existing value's index changes, the transform function is not Anytime an existing value's index changes, the transform function is not
rerun, instead the source index for that value will update, causing anything rerun, instead the source index for that value will update, causing anything
depending on it to update too. depending on it to update too.
Returns a state containing an array of all objects returned by the
transform.
::: warning ::: warning
`transform()` cannot yield. Having primitive values in the input source table can cause unexpected
::: behavior, as duplicate values can result in multiple tranforms being ran for
a single value, meaning there can be multiple source indexes bound to the
::: warning same UI element. Strict mode has checks for this.
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** - **Example**
@ -150,11 +179,11 @@ Maps each value in a table source to an object.
local displays = values(items, function(item, i) local displays = values(items, function(item, i)
return ItemDisplay { return ItemDisplay {
Name = item.Name Name = function()
return i() .. ": " .. item.Name
end
Image = "rbxassetid://" .. item.icon, Image = "rbxassetid://" .. item.icon,
LayoutOrder = i
} }
end) end)
``` ```
@ -181,6 +210,9 @@ Maps each value in a table source to an object.
In most cases, both functions will appear to have the same behavior. In most cases, both functions will appear to have the same behavior.
The main difference is performance, picking the right function to use can The main difference is performance, picking the right function to use can
result in less property updates and less re-renders. result in less property updates and less re-renders. One case to note is
that `values()` works nicely when animating re-ordering of instances, since
the value is not destroyed when indexes are changed, and the source index
can easily be put through a spring.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View file

@ -2,12 +2,17 @@
## cleanup() ## cleanup()
Runs a callback anytime a reactive scope is re-ran. Runs a callback anytime a reactive scope is reran or destroyed.
- **Type** - **Type**
```lua ```lua
function cleanup(callback: () -> ()) function cleanup(callback: () -> ())
function cleanup(obj: Destroyable)
function cleanup(obj: Disconnectable)
type Destroyable = { destroy: () -> () }
type Disconnectable = { disconnect: () -> () }
``` ```
- **Example** - **Example**
@ -24,23 +29,10 @@ Runs a callback anytime a reactive scope is re-ran.
end) end)
``` ```
```lua
local data = source(1)
derive(function()
local label = create "TextLabel" { Text = data() }
cleanup(function()
label:Destroy()
end)
return label
end)
```
## untrack() ## untrack()
Runs a given function where any sources read will not track its reactive scope. Runs a given function where any sources read will not be tracked by a reactive
scope.
- **Type** - **Type**
@ -70,4 +62,33 @@ Runs a given function where any sources read will not track its reactive scope.
print(sum()) -- 2 print(sum()) -- 2
``` ```
## read()
Utility used to read a value that is either a primitive or a source. Sources
read can still be tracked inside a reactive scope.
- **Type**
```lua
function read<T>(value: T | () -> T): T
```
## batch()
Runs a given function where any source updates made within the function do not
trigger effects until after the function runs.
- **Type**
```lua
function batch(fn: () -> ())
```
- **Details**
Improves performance when an effect depends on multiple sources, and those
sources need to be updated. Updating those sources inside a batch call will
only cause the effect to run once after the batch call ends instead of after
each time a source is updated.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View file

@ -6,6 +6,9 @@ Strict mode is library-wide and can get set by doing:
vide.strict = true vide.strict = true
``` ```
It is automatically enabled when Vide is first required and not running in O2
optimization level.
Strict mode is designed to help the development process by adding safety checks Strict mode is designed to help the development process by adding safety checks
and identifying improper usage. and identifying improper usage.
@ -15,12 +18,13 @@ Currently, strict mode will:
2. Run effects 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. 3. Throw an error if yields occur where they are not allowed.
4. Checks for `indexes()` and `values()` returning primitive values. 4. Checks for `indexes()` and `values()` returning primitive values.
5. Checks for duplicate nested properties at same depth. 5. Checks for `values()` input having duplicate values.
6. Better error reporting and stack traces. 6. Checks for duplicate nested properties at same depth.
7. Checks for multiple `cleanup()` calls in the same function scope. 7. Better error reporting and stack traces + creation traces of property bindings.
By rerunning sources and effects, any side-effects are made more apparent. By rerunning derived sources and effects twice each time they update,it helps
This also helps ensure that cleanups are being handled correctly. ensure that derived source computations are pure, and that any
cleanups made in derived sources or effects are done correctly.
Accidental yielding within reactive scopes can break Vide's reactive graph, Accidental yielding within reactive scopes can break Vide's reactive graph,
which strict mode can catch. which strict mode can catch.
@ -29,5 +33,6 @@ As well as additional safety checks, Vide will dedicate extra resources to
recording and better emitting stack traces where errors occur, particularly recording and better emitting stack traces where errors occur, particularly
when binding properties to sources. when binding properties to sources.
It is recommend to develop UI with strict mode and to disable it when pushing to It is recommended to develop UI with strict mode and to disable it when pushing to
production. production. In Roblox, production code compiles at O2 by default, so you don't
need to worry about disabling strict mode unless you have manually enabled it.

View file

@ -8,6 +8,7 @@
}, },
"devDependencies": { "devDependencies": {
"vitepress": "^1.0.0-rc.4" "vitepress": "1.0.0-rc.25",
"vitepress-plugin-mermaid": "2.0.14"
} }
} }

67
docs/public/full_logo.svg Normal file
View file

@ -0,0 +1,67 @@
<svg width="150" height="88" viewBox="15 7 115 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_1_41)">
<g clip-path="url(#clip0_1_41)">
<rect x="18" y="12" width="110" height="48" rx="8" fill="url(#paint0_radial_1_41)"/>
<path d="M65.4148 32.2727L68.0057 39.6193H68.108L70.6989 32.2727H71.8068L68.6023 41H67.5114L64.3068 32.2727H65.4148ZM77.223 32.2727V41H76.1662V32.2727H77.223ZM85.0352 41H82.342V32.2727H85.1545C86.0011 32.2727 86.7255 32.4474 87.3278 32.7969C87.93 33.1435 88.3917 33.642 88.7127 34.2926C89.0337 34.9403 89.1942 35.7159 89.1942 36.6193C89.1942 37.5284 89.0323 38.3111 88.7085 38.9673C88.3846 39.6207 87.913 40.1236 87.2937 40.4759C86.6744 40.8253 85.9215 41 85.0352 41ZM83.3988 40.0625H84.967C85.6886 40.0625 86.2866 39.9233 86.761 39.6449C87.2354 39.3665 87.5891 38.9702 87.8221 38.456C88.055 37.9418 88.1715 37.3295 88.1715 36.6193C88.1715 35.9148 88.0565 35.3082 87.8263 34.7997C87.5962 34.2884 87.2525 33.8963 86.7951 33.6236C86.3377 33.348 85.7681 33.2102 85.0863 33.2102H83.3988V40.0625ZM93.967 41V32.2727H99.234V33.2102H95.0238V36.1591H98.9613V37.0966H95.0238V40.0625H99.3022V41H93.967ZM109.022 32.2727H111.127V37.902C111.127 38.5526 110.973 39.1193 110.663 39.6023C110.353 40.0824 109.922 40.4545 109.368 40.7188C108.814 40.9801 108.17 41.1108 107.437 41.1108C106.696 41.1108 106.048 40.9801 105.494 40.7188C104.94 40.4545 104.51 40.0824 104.203 39.6023C103.896 39.1193 103.743 38.5526 103.743 37.902V32.2727H105.852V37.7188C105.852 38.0199 105.917 38.2884 106.048 38.5241C106.181 38.7599 106.368 38.9446 106.606 39.0781C106.845 39.2116 107.122 39.2784 107.437 39.2784C107.752 39.2784 108.028 39.2116 108.264 39.0781C108.502 38.9446 108.689 38.7599 108.822 38.5241C108.956 38.2884 109.022 38.0199 109.022 37.7188V32.2727ZM117.559 32.2727V41H115.45V32.2727H117.559Z" fill="white"/>
<g filter="url(#filter1_ddd_1_41)">
<path d="M39.4019 47.5C40.5566 49.5 43.4434 49.5 44.5981 47.5L56.4546 26.9639C57.2786 25.5367 56.1196 23.7812 54.4834 23.9783L48.3548 24.7164C46.9029 24.8913 45.6622 25.8444 45.1191 27.2021L42 35L38 25L29.5166 23.9783C27.8804 23.7812 26.7214 25.5367 27.5454 26.9639L39.4019 47.5Z" fill="url(#paint1_radial_1_41)"/>
<path d="M38 25L42 35L46 45.0718C45.1412 46.5593 42.9736 46.4973 42.2012 44.9632L31.7728 24.25L38 25Z" fill="url(#paint2_radial_1_41)"/>
<path d="M56.4546 26.9639L44.5981 47.5C43.4434 49.5 40.5566 49.5 39.4019 47.5L38 45.0718L34 38.1436C35.6267 40.9319 39.7492 40.627 40.9481 37.6298L42 35L46 25L54.4834 23.9783C56.1196 23.7812 57.2786 25.5367 56.4546 26.9639Z" fill="url(#paint3_radial_1_41)"/>
</g>
</g>
</g>
<defs>
<filter id="filter0_d_1_41" x="0" y="0" width="150" height="88" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect1_dropShadow_1_41"/>
<feOffset dx="2" dy="8"/>
<feGaussianBlur stdDeviation="12"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.0690196 0 0 0 0 0.313726 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_41"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_41" result="shape"/>
</filter>
<filter id="filter1_ddd_1_41" x="13.2732" y="11.9631" width="61.4536" height="57.0369" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="2" dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_41"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="1" dy="2"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_1_41" result="effect2_dropShadow_1_41"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_1_41" result="effect3_dropShadow_1_41"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_1_41" result="shape"/>
</filter>
<radialGradient id="paint0_radial_1_41" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(73 12) rotate(90) scale(48 58.022)">
<stop stop-color="#3D506C"/>
<stop offset="1" stop-color="#2F415C"/>
</radialGradient>
<radialGradient id="paint1_radial_1_41" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(42 51.5) rotate(-90) scale(28 32.3316)">
<stop stop-color="#2A57A2"/>
<stop offset="1" stop-color="#407BBA"/>
</radialGradient>
<radialGradient id="paint2_radial_1_41" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39 24) rotate(74.3578) scale(25.9615 15.2089)">
<stop stop-color="#1D314F"/>
<stop offset="1" stop-color="#0D1A2E"/>
</radialGradient>
<radialGradient id="paint3_radial_1_41" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(34 52) rotate(-49.3987) scale(36.8782 31.6434)">
<stop stop-color="#5792D9"/>
<stop offset="1" stop-color="#6AA8E9"/>
</radialGradient>
<clipPath id="clip0_1_41">
<rect x="18" y="12" width="110" height="48" rx="8" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -1,4 +1,39 @@
<svg width="150" height="300" viewBox="0 0 334 478" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="384" height="384" viewBox="8 8 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 1.13748V276.5H254.402L254 276.973V278H253.127L166.5 380L78.5984 276.5H80V1.20867L0 78.4638V322.5L167 478L334 322.5V78.3926L254 1.13748Z" fill="#223450"/> <g filter="url(#filter0_ddd_1_134)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 278V0.715805L296.904 42V292L167 417L37.0962 292V42L80 0.715805V278H80.3975L167 362L253.603 278H254Z" fill="#4796F3"/> <path d="M21.4019 35.5C22.5566 37.5 25.4434 37.5 26.5981 35.5L38.4546 14.9639C39.2786 13.5367 38.1196 11.7812 36.4834 11.9783L30.3548 12.7164C28.9029 12.8913 27.6622 13.8444 27.1191 15.2021L24 23L20 13L11.5166 11.9783C9.88038 11.7812 8.72139 13.5367 9.54541 14.9639L21.4019 35.5Z" fill="url(#paint0_radial_1_134)"/>
<path d="M20 13L24 23L28 33.0718C27.1412 34.5593 24.9736 34.4973 24.2012 32.9632L13.7728 12.25L20 13Z" fill="url(#paint1_radial_1_134)"/>
<path d="M38.4546 14.9639L26.5981 35.5C25.4434 37.5 22.5566 37.5 21.4019 35.5L20 33.0718L16 26.1436C17.6267 28.9319 21.7492 28.627 22.9481 25.6298L24 23L28 13L36.4834 11.9783C38.1196 11.7812 39.2786 13.5367 38.4546 14.9639Z" fill="#4896F3"/>
</g>
<defs>
<filter id="filter0_ddd_1_134" x="-4.72681" y="-0.0369511" width="61.4536" height="57.037" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="2" dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_134"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="1" dy="2"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_1_134" result="effect2_dropShadow_1_134"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_1_134" result="effect3_dropShadow_1_134"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_1_134" result="shape"/>
</filter>
<radialGradient id="paint0_radial_1_134" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24 39.5) rotate(-90) scale(28 32.3316)">
<stop stop-color="#24447F"/>
<stop offset="1" stop-color="#3661A2"/>
</radialGradient>
<radialGradient id="paint1_radial_1_134" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(21 12) rotate(74.3578) scale(25.9615 15.2089)">
<stop stop-color="#1D314F"/>
<stop offset="1" stop-color="#0D1A2E"/>
</radialGradient>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 487 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

@ -0,0 +1,188 @@
# Nested Reactive Scopes
Nesting reactive scopes gives you finer control over the reactive graph, but
needs more work to do. The built-in control flow functions try to cover the
most common cases, but they do not cover all of them.
This tutorial will demonstrate how to implement a `show()` control flow function
using just sources and effects.
```lua
local mount = vide.mount
local source = vide.source
local show = vide.show
local function Counter()
local count = source(0)
return create "TextButton" {
Text = count,
Activated = function() count(count() + 1) end
}
end
mount(function()
local toggled = source(true)
show(toggled, Button)
end)
```
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#1C1C1F"
}
}}%%
graph
subgraph mount
direction LR
toggle --> show
subgraph show[show effect]
text[Text effect]
end
end
```
Above is the reactive graph for `show()`. It creates a new effect depending on
`toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The
`show` effect calls `Counter`, which creates a new reactive scope to update its
text whenever `count` changes. As per the rules of reactive scopes, a reactive
scope rerunning will destroy any reactive scope created within it. So the text
effect's reactive scope is destroyed whenever the show effect is rerun.
The same can be achieved without the use of `show()`:
```lua
local mount = vide.mount
local source = vide.source
local effect = vide.effect
local cleanup = vide.cleanup
local function Counter()
local count = source(0)
return create "TextButton" {
Text = count,
Activated = function() count(count() + 1) end
}
end
mount(function()
local toggled = source(true)
effect(function()
if toggled() then
local destroy = mount(Button)
cleanup(destroy)
end
end)
end)
```
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#1C1C1F"
}
}}%%
graph
subgraph mount
direction LR
toggle --> effect
subgraph effect
subgraph mount2[inner mount]
text[Text effect]
end
end
end
```
This is another way to achieve the same. Here we use `mount()` within the effect
to manually create and destroy a new reactive scope whenever the effect reruns.
Alternatively, instead of using `mount()`, a new reactive scope can be created
directly within the effect:
```lua
local mount = vide.mount
local source = vide.source
local effect = vide.effect
local untrack = vide.untrack
local function Counter()
local count = source(0)
return create "TextButton" {
Text = count,
Activated = function() count(count() + 1) end
}
end
mount(function()
local toggled = source(true)
effect(function()
if toggled() then
untrack(Button)
end
end)
end)
```
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#1C1C1F"
}
}}%%
graph
subgraph mount
direction LR
toggle --> effect
subgraph effect
text[Text effect]
end
end
```
Without the use of `untrack()`, an error would occur, since Vide does not allow
the creation of reactive scopes inside reactive scopes that are tracking. The
reason for this, is because if the `Counter` component reads from a source
internally, that can cause the reactive scope calling `Counter()` to track that
source, causing unintentional reruns. As a guard against this, you are forced to
use `untrack()` to create nested reactive scopes.
The final result is the same as using the `show()` component. An effect is
created which creates the counter, which creates its own reactive scope. The
effect rerunning causes the counter's internal reactive scope to be destroyed,
making sure everything is cleaned up.

View file

View file

@ -0,0 +1 @@
# show()

View file

@ -0,0 +1 @@
# switch()

View file

@ -0,0 +1 @@
# indexes()

View file

@ -0,0 +1 @@
# values()

View file

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

View file

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

View file

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

View file

@ -1,47 +1,25 @@
# Introduction # Introduction
This is a brief tutorial designed to give you a quick run through the usage of This is a tutorial that introduces the concepts and usage of Vide.
Vide.
Vide is heavily inspired by [Solid](https://www.solidjs.com/). Vide is heavily inspired by [Solid](https://www.solidjs.com/).
This tutorial assumes familiarity with Luau and Roblox UI.
## Why Vide? ## Why Vide?
Creating UI is a slow and tedious process. The purpose of Vide is to make UI Creating UI is complicated, slow, and tedious.
declarative and concise, making it faster to create and more importantly easier
to maintain. Vide achieves this using a reactive style of programming which Vide tries to simplify and speed up this process by providing a declarative and
allows you to focus on the flow of data through your application without reactive of style programming, which lets you focus more on designing the UI
worrying about manually updating UI instances. itself and not having to manually update or reparent UI instances.
Some of the main focuses behind Vide's design choices: Some of the main focuses behind Vide's design choices:
- Concise syntax to reduce verbosity as much as possible. - Minimal syntax.
- Reducing the amount of imports needed for usage by leveraging Luau's syntax - Complete typechecking
and semantics. - Independence from instances.
- Being completely typecheckable.
- Flexibility with integrating other libraries and allowing users to use their
own patterns.
- Independence from instance lifetimes.
- A powerful reactive system that can update specific properties as a result of
state changes, updates are immediate with no diffing needed.
## Structure Of A Vide App As with most declarative libraries, there is an initial learning curve to
understand the concepts and usage. This tutorial tries to comprehensively
The entry point for all Vide apps is the `mount()` function. This function cover these concepts and usage, more so than you need just to use it.
sets up Vide's reactivity system. It takes and calls a function that should
create your entire app, and will apply its result to a target.
In Vide, your app should be composed of functions, each function creates a
specific part of your app, and can be reused if needed. These functions are
called *components*.
```lua
local function App()
return create "ScreenGui" {
create "TextLabel" { Text = "hi" }
}
end
mount(App, game.StarterGui)
```

View file

@ -1,43 +0,0 @@
# Actions
Actions in Vide are special callbacks that you can pass along with properties,
which will be called when those properties are being processed with the instance
being assigned to, allowing you to run custom code.
```lua
local action = vide.action
```
```lua
create "TextLabel" {
Text = "test",
action(function(instance)
print(instance.Text)
end)
}
-- will print "test"
```
Actions can be wrapped with functions to re-use specific behaviors. Below is
an example of an action used to listen for property changes:
```lua
local function changed(property: string, callback: (new) -> ())
return action(function(instance)
instance:GetPropertyChangedSignal(property):Connect(function()
callback(instance[property])
end)
end)
end
local output = source ""
create "TextBox" {
changed("Text", output)
}
```
The source `output` will be updated with the new property value any time it is
changed externally.

View file

@ -0,0 +1,51 @@
# Cleanup
Sometimes you may need to do some cleanup when destroying a component or after
a side-effect from a source update. Vide provides a function `cleanup()` which
is used to queue a cleanup callback for the next time a reactive scope is rerun
or destroyed.
```lua
local mount = vide.mount
local source = vide.source
local cleanup = vide.cleanup
local function Timer()
local count = source(0)
local con = game:GetService("RunService").Heartbeat:Connect(function(dt)
count(count() + dt)
end)
cleanup(function()
con:Disconnect()
end)
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = function()
return "seconds: " .. math.floor(count())
end,
}
end
local unmount = mount(Timer)
unmount() -- all queued cleanups are ran, heartbeat connection disconnected
```
In the above example, this allows us to disconnect the heartbeat connection
when the reactive scope responsible for creating the timer component is
destroyed, such as when it is unmounted.
::: tip
Roblox instances do not need to be explicitly destroyed for their
memory to be freed, they only need to be parented to `nil`. So there is no
need to use `cleanup()` to destroy instances. However, be wary of connecting
a function that references an instance to an event from the same instance,
this causes the instance to reference itself and never be freed. In such a case
you would need to use `cleanup()` to disconnect this connection or to explicitly
destroy the instance.
:::

View file

@ -0,0 +1,202 @@
# Control Flow
Eventually you may need a way to dynamically create and destroy UI elements
resulting from source updates. Vide provides functions to help you do this,
known as *control flow* functions.
These functions return new sources, which hold the instances to be displayed.
Control flow functions run their components in a new reactive scope, which can
be destroyed independently of the reactive scope that called the control flow
function. This means parts of your app can be independently created and
destroyed.
## switch()
`switch()` condtionally displays one instance at a time. It uses a table to map
a source value to a component.
```lua
local source = vide.source
local switch = vide.switch
local function Button(props: {
Text: string,
Activated: () -> ()
})
local hovered = source(false)
return create "TextButton" {
Text = props.Text,
Activated = props.Activated,
TextColor3 = function()
return hovered() and Color3.new(1, 1, 1) or Color3.new(.7, .7, .7)
end,
MouseEnter = function() hovered(true) end,
MouseLeave = function() hovered(false) end
}
end
local function JoinMenu()
local joined = source(false)
local function JoinButton()
return Button {
Text = "Join",
Activated = function() joined(true) end
}
end
local function LeaveButton()
return Button {
Text = "Leave"
Activated = function() joined(false) end
}
end
return create "Frame" {
switch(joined) {
[true] = LeaveButton,
[false] = JoinButton
}
}
end
```
The reactive graph for the above example:
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#1C1C1F"
}
}}%%
graph
subgraph root["root scope"]
direction LR
joined --> switch -.- subroot
subgraph subroot["switch scope"]
direction LR
effect["TextColor3 effect"]
end
end
```
A `switch()` call creates a new effect and a new scope as seen in the above
graph. Whenever `menu` updates, it causes the `switch` effect to run, which
will destroy and recreate the switch scope with the new component.
This will also destroy the internal effect that the button uses to highlight
itself when it is hovered, each time the switch is rerun.
## indexes()
Often, you will have a table of values with each value displayed in a similar
manner. Rather than manually looping over each value to generate a corresponding
UI element, `indexes()` allows you to create elements each corresponding to a
table index, to display the value at that index.
```lua
local todoList = source {
"finish the crash course",
"star vide's GitHub"
}
local function TodoList(props: { list: () -> Array<string> })
return create "Frame" {
create "UIListLayout" {},
indexes(todoList, function(todo, i)
return create "TextLabel" {
Text = function()
return i .. ": " .. todo()
end,
LayoutOrder = i
}
end)
}
end
TodoList { list = todoList }
```
For each index in the given source table, the given function will be called
with:
1. a source containing the value of the index
2. the index itself
When the value at an index is changed, the function is not reran. Instead, the
given source for that index is updated.
Any time the input source table is updated, the given function will be ran for
any newly added indexes, while any removed indexes (indexes now with a `nil`
value), will have its corresponding reactive scope destroyed to clean up that
element.
`indexes()` is said to *map* each table index to a new UI element that can
update to display the current value at that index. Each table index is given a
single corresponding UI element.
The reactive graph for the above example:
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#1C1C1F"
}
}}%%
graph
subgraph root ["root scope"]
direction LR
todoList --> indexes -.- subroot1 & subroot2
subgraph subroot1 ["indexes scope 1"]
direction LR
value1[todo] --> prop1["prop binding"]
end
subgraph subroot2 ["indexes scope 2"]
direction LR
value2[todo] --> prop2[prop binding]
end
end
```
One thing to note regarding table sources, is that when you edit a table in a
source, you must set that table again to actually update the source.
```lua
local src = source { 1, 2 }
local data = src()
table.insert(data, 3) -- no effects will run
src(data) -- effects will run
```
Together, these control flow functions cover the majority of cases where you
need to dynamically create and destroy parts of your UI.
If you need to do something that these control flow functions cannot, you can
always use `mount()` within an effect to dynamically create and destroy
components on your own terms. Just remember to use `cleanup()` to unmount when
the effect reruns.

View file

@ -27,7 +27,7 @@ function Menu(props: {
Size: UDim2 Size: UDim2
}) })
return Background { return Background {
Color = props.COlor, Color = props.Color,
AnchorPoint = props.AnchorPoint, AnchorPoint = props.AnchorPoint,
Position = props.Position, Position = props.Position,
Size = props.Size Size = props.Size
@ -77,7 +77,8 @@ be parented.
```lua ```lua
type Children = { type Children = {
Children = Array<Instance> -- also can optionally pass a source that returns an array of children too
Children = Array<Instance> | () -> Array<Instance>
} }
local function List(props: Children & Layout) local function List(props: Children & Layout)
@ -107,8 +108,8 @@ properties, this can be used to create overridable default properties.
local function List(props: Children & Layout) local function List(props: Children & Layout)
return create "Frame" { return create "Frame" {
props.Children, props.Children,
props.Layout, props.Layout,
-- can be overriden by `props.Layout` -- can be overriden by `props.Layout`
AnchorPoint = Vector2.new(0.5, 0), AnchorPoint = Vector2.new(0.5, 0),
Position = UDim2.fromScale(0.5, 0), Position = UDim2.fromScale(0.5, 0),

View file

@ -0,0 +1,53 @@
# Actions
Actions in Vide are special callbacks that you can pass along with properties,
to run some code on an instance receiving them.
```lua
local action = vide.action
```
```lua
create "TextLabel" {
Text = "test",
action(function(instance)
print(instance.Text)
end)
}
-- will print "test"
```
Actions can be wrapped with functions for reuse. Below is an example of an
action used to listen for property changes:
```lua
local action = vide.action
local cleanup = vide.cleanup
local function changed(prop: string, callback: (new) -> ())
return action(function(instance)
local connection = instance:GetPropertyChangedSignal(prop):Connect(function()
callback(instance[property])
end)
-- remember to clean up the connection when the reactive scope the action
-- is ran in is destroyed, so the instance can be garbage collected
cleanup(connection)
end)
end
local output = source ""
local instance = create "TextBox" {
changed("Text", output)
}
instance.Text = "foo"
print(output()) -- "foo"
```
The source `output` will be updated with the new property value any time it is
changed externally.

View file

@ -0,0 +1,35 @@
# Strict Mode
While developing UI with Vide, you should use Vide's strict mode, which can
be set with `vide.strict = true` once when you first require Vide. Strict mode
will add extra safety checks and emit better error traces, particularly when
errors occur in property bindings.
Strict mode is automatically enabled when Vide is required in O0 or O1
optimization (default studio level). You can `vide.strict = false` if you do not
want this.
Strict mode will run derived sources and effects twice each time they update.
This is to help ensure that derived source computations are pure, and that any
cleanups made in derived sources or effects are done correctly.
```lua
local source = vide.source
local effect = vide.effect
vide.strict = true
local count = source(0)
local ran = 0
effect(function()
ran += 1
end)
print(ran) -- 2
count(1)
print(ran) -- 4
```
A full list of what strict mode will do can be found
[here](../../api/strict-mode).

View file

@ -0,0 +1,123 @@
# Concepts Summary
A summary of all the concepts covered during the crash course.
## Source
A source of data.
Stores a single value that can be updated.
Created with `source()`.
# Derived Source
A new source composed of other sources.
Created with a plain function or with `derive()`.
## Effect
Anything that happens in response to a source update.
Created with `effect()`.
## Reactive Scope
A scope created by certain functions such as:
- `root()`
- `effect()`
- `derive()`
Reactive scopes can:
- track sources that are read from within.
- rerun when a tracked source updates.
- track new reactive scopes created from within.
## Scope Owners
A reactive scope created within another reactive scope is *owned* by the other
reactive scope, with the exception of the reactive scope created by `root()`.
When a reactive scope is rerun or destroyed, all reactive scopes owned by it are
automatically destroyed.
`root()`, which `mount()` uses internally, creates a reactive scope with no
owner, since it must be destroyed manually using a destructor
returned.
## Cleanup
Arbitrary code to run whenever a reactive scope is rerun or destroyed.
Queue a function to run using `cleanup()`.
## Tracking
Sources read from within a reactive scope will be tracked. This can be disabled
using `untrack()`, which will make reactive scopes temporarily ignore sources
read.
The reactive scope created by `root()` is non-tracking by default.
As a guard against misusage, a reactive scope cannot be created within a
reactive scope, unless it is made non-tracking using `untrack()`.
## Reactive Graph
The combination of reactive scopes can viewed graphically, called a
*reactive graph*. This can be a more intuitive way to think of the
relationships between effects and the sources they depend on.
### Code
```lua
local count = source(0)
root(function()
local text = derive(function()
return "count: " .. text()
end)
effect(function()
print(text())
end)
end)
```
### Graph resulting from code
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#1C1C1F"
}
}}%%
graph LR
subgraph root
text --> effect
end
count --> text
```
Notes:
- Since `count` is a source, not an effect, it can exist
outside of a root reactive scope.
- An update to `count` will cause `text` to rerun, which
then causes `effect` to rerun.
- When the root reactive scope is destroyed, `text` and
`effect` will be destroyed alongside it, since they are
owned by it. `count` will be untouched and future updates
to `count` will have no effect.

View file

@ -6,53 +6,43 @@ Instances are created using `create()`.
properties to assign when creating a new instance for that class. properties to assign when creating a new instance for that class.
Luau allows us to omit parentheses `()` when calling functions with string or Luau allows us to omit parentheses `()` when calling functions with string or
table literals which Vide takes advantage of for brevity. table literals which is recommended to use for brevity.
```lua ```lua
local vide = require(vide)
local mount = vide.mount
local create = vide.create local create = vide.create
local function App() return create "ScreenGui" {
return create "ScreenGui" { create "Frame" {
create "Frame" { AnchorPoint = Vector2.new(0.5, 0.5),
AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromScale(0.4, 0.7),
Size = UDim2.fromScale(0.4, 0.7),
create "TextLabel" { create "TextLabel" {
Text = "hi" Text = "hi"
}, },
create "TextLabel" { create "TextLabel" {
Text = "bye" Text = "bye"
}, },
create "TextButton" { create "TextButton" {
Text = "click me", Text = "click me",
Activated = function() Activated = function()
print "clicked!" print "clicked!"
end end
}
} }
} }
end }
mount(App, game.StarterGui)
``` ```
Assign a value to a string key to set a property, and assign a value to a Assign a value to a string key to set a property, and assign a value to a
number key to set a child. Events can be connected to by assigning a function number key to set a child. Events can be connected to by assigning a function
to a string key. to a string key.
You can also use a shorthand to create datatypes instead of explicitly typing ::: warning
out the class name and constructor. The table will be unpacked into the `.new()` When creating an instance with no properties, it is important to not forget to
constructor of the property's type. actually call the constructor: `create "Frame" {}` and not `create "Frame"`.
To be clear, `create "Frame"` returns a *function* which is a constructor for
```lua that class, not an instance of that class.
create "Frame" { :::
AnchorPoint = { 0.5, 1 },
UDim2 = { 0.5, 0, 0.5, 0 }
}
```

View file

@ -1,12 +1,16 @@
# Components # Components
Components are custom-made reusable pieces of UI made from other pieces of UI. Vide encourages separating different parts of your UI into functions called
*components*.
By using components you can make your application more modular and better A component is a function that creates and returns a piece of UI.
organized.
This is a way to separate your UI into small chunks that you can reuse and put
together.
::: code-group
```lua [Button.luau] ```lua [Button.luau]
local vide = require(vide)
local create = vide.create local create = vide.create
local function Button(props: { local function Button(props: {
@ -16,11 +20,14 @@ local function Button(props: {
}) })
return create "TextButton" { return create "TextButton" {
BackgroundColor3 = Color3.fromRGB(50, 50, 50), BackgroundColor3 = Color3.fromRGB(50, 50, 50),
TextColor3 = Color3.fromRGB(255, 255, 255),
Size = UDim2.fromOffset(200, 150), Size = UDim2.fromOffset(200, 150),
Position = props.Position, Position = props.Position,
Text = props.Text, Text = props.Text,
Activated = props.Activated Activated = props.Activated,
create "UICorner" {}
} }
end end
@ -28,8 +35,6 @@ return Button
``` ```
```lua [App.luau] ```lua [App.luau]
local vide = require(vide)
local mount = vide.mount
local create = vide.create local create = vide.create
local Button = require(Button) local Button = require(Button)
@ -38,29 +43,35 @@ local function App()
return create "ScreenGui" { return create "ScreenGui" {
Button { Button {
Position = UDim2.fromOffset(200, 200), Position = UDim2.fromOffset(200, 200),
Text = "click me!", Text = "back",
Activated = function() Activated = function()
print "clicked" print "go to previous page"
end
},
Button {
Position = UDim2.fromOffset(400, 200),
Text = "next",
Activated = function()
print "go to next page"
end end
} }
} }
end end
mount(App, game.StarterGui) App().Parent = game.StarterGui
``` ```
Above is a simple example of a button component with a set color and size, :::
being reused across files.
Above is a simple example of a button component being used across files.
A single parameter `props` is used to pass properties to the component. A single parameter `props` is used to pass properties to the component.
Components allow you to *encapsulate* behavior. You can only modify the You can only modify the component in ways that you allow in the component,
component in ways that you allow in the component. through the `props` parameter.
This also promotes code reusability. Anytime you want a new button all you do To create a new button all you must do is call the `Button` function, passing in
is call `Button {}` instead of creating and setting every property each time. values. This saves having to create and set every property each time. Also, when
When changing the button in future, any changes to the button file will be updating the button component in future, any changes to the button file will be
reflected anywhere the button is used throughout your app. seen anywhere the button is used in your app.
This can be extended to much more complicated UI.

View file

@ -1,31 +1,14 @@
# Source # Sources
*Sources* in Vide are special objects that store a single value. They are the Sources are special objects that store a single value. They are the core of
core of reactivity in Vide, as updates to a source can automatically update Vide's reactivity. They are called sources because they act as sources of data.
properties or other sources depending on that source.
A source in Vide can be created using `source()`. A source can be created using `source()`.
```lua ```lua
local vide = require(vide)
local source = vide.source local source = vide.source
local function Counter() local count = source(0)
local count = source(0)
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = count,
Activated = function()
count(count() + 1)
end
}
end
mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui)
``` ```
The value passed to `source()` is the initial value of the source. The value passed to `source()` is the initial value of the source.
@ -37,15 +20,22 @@ by calling it with no arguments.
count(count() + 1) -- increment count by 1 count(count() + 1) -- increment count by 1
``` ```
Each call of `Counter {}` will create a new counter, each maintaining their Sources can be *derived* by wrapping them in functions. A wrapped source
own count. effectively becomes a new source.
When you assign a function to a non-event property, Vide will immediately run it ```lua
and check what sources were read from. When updating those sources again after, local count = source(0)
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 local text = function()
of your program changes. You just define how the data maps to UI, and Vide's return "count: " .. tostring(count())
reactive system will automatically update any properties depending on sources end
that are updated.
print(text()) -- "count: 0"
count(1)
print(text()) -- "count: 1"
```
Sources on their own aren't very special, the above can be achieved with plain
variables. The real use for sources become apparent when used in combination
with *effects*. Similar to a signal and connection, a source and effect allows
you to do things like automatically updating UI when a source is updated.

View file

@ -1,45 +1,51 @@
# Effect # Effects
An effect is a function that is run anytime a source updates. They are called Effects are functions that are ran in response to source updates. They are
effects because they can produce side-effects when reacting to source changes. called effects because they cause *side-effects* when reacting to source
updates.
Effects are created using `effect()`. Effects are created using `effect()`.
```lua ```lua
local vide = require(vide)
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
local function Counter() local count = source(0)
local count = source(0)
effect(function() effect(function()
print("count has updated to: " .. count()) print("count: " .. count())
end) end)
return create "TextButton" { -- "count: 0" printed
Position = UDim2.fromOffset(300, 300), count(1)
Size = UDim2.fromOffset(200, 50), -- "count: 1" printed
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. The callback given to `effect()` is ran immediately in a *reactive scope*. Any
source read from inside a reactive scope will be tracked, so when any of those
sources update, the effect will be reran too.
`effect()` creates an explicit side-effect. There are other side-effects in the Reactive scopes also track derived sources, it doesn't matter how deeply nested
above code sample. The setting of `Text = count` creates another side-effect; inside a function a source is.
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 ```lua
reactive system. local source = vide.source
local effect = vide.effect
You should not update other sources using an effect. Improper usage can lead to local count = source(1)
unecessary updates and infinite loops.
local doubled = function()
return count() * 2
end
effect(function()
print("doubled count: " .. doubled())
end)
-- "doubled count: 2" printed
count(2)
-- "doubled count: 4" printed
```
If a source is updated with the same value it already had, it will not rerun
effects depending on it.

View file

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

View file

@ -0,0 +1,80 @@
# Root Reactive Scopes
Reactive scopes cannot be created on their own - they must be created within
another reactive scope so that it can be tracked and later destroyed when it is
no longer needed.
This is the purpose of `mount()`, which creates an initial "root", or
"top-level" reactive scope, which all other reactive scopes, such as
ones created by `effect()`, can stem from.
When this root reactive scope is destroyed, it will ensure all other reactive
scopes created within it are also destroyed, ensuring everything is cleaned up
properly.
```lua
local source = vide.source
local effect = vide.effect
local function App()
local count = source(0)
effect(function()
print(count())
end)
end
App() -- will error since effect() was not called within a reactive scope
vide.mount(App) -- works!
```
Mounting returns a function that when called will destroy its reactive scope,
along with any other reactive scopes created inside it.
```lua
local unmount = mount(App)
unmount()
```
Vide's reactivity can be represented graphically, as a *reactive graph*.
The reactive graph for the above example looks like so:
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#161618"
}
}}%%
graph
subgraph root
direction LR
count --> effect
end
```
When the root reactive scope created by `mount()` is destroyed, the `effect`
scope will also be destroyed since it was created within it.
This is important because you may have an effect that updates the property of a
UI instance, meaning the effect is referencing and holding that instance in
memory. The effect being destroyed will remove this reference, allowing the
instance to be garbage collected.
You don't need to worry about ensuring all your effects are created within a
root reactive scope, since you should be creating all your UI and corresponding
effects within a top-level `mount()` call that puts all your UI together. So it
is safe to assume that any effect you create will be created under this top
level scope. Vide will prevent you from accidently doing otherwise anyways.

View file

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

View file

@ -0,0 +1,75 @@
# Stateful Components
A stateful component is a component that stores some data internally.
Stateful components in Vide are created using sources and effects - sources to
store the data, and effects to display the data.
## Internal State
```lua
local create = vide.create
local source = vide.source
local effect = vide.effect
local function Counter()
local count = source(0)
local instance = create "TextButton" {
Activated = function()
count(count() + 1)
end
}
effect(function()
instance.Text = "count: " .. count()
end)
return instance
end
mount(Counter, game.StarterGui)
```
Above is an example of a counter component, that when clicked, will increment
its internal count, and automatically update its text to reflect that count.
Each instance of `Counter()` will maintain its own independent count, since the
count source is created inside the component.
We use `mount()` to create the counter within a reactive scope, which also takes
a second argument to parent the counter to another instance.
## External State
External sources can also be passed into components for them to use.
```lua
local function Counter(props: { count: () -> number })
local count = props.count
local instance = create "TextButton" {
Activated = function()
count(count() + 1)
end
}
effect(function()
instance.Text = "count: " .. count()
end)
return instance
end
local count = source(0)
Counter {
count = count
}
count(1) -- the Counter component will update to display this count
```
Sources can be created internally or passed in from externally, there are no
restrictions on how they are used as long as the effect using it is created
within a reactive scope.

View file

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

View file

@ -0,0 +1,66 @@
# Property Binding
Explicitly creating effects to update properties can be tedious. Vide provides a
way to *implicitly* create an effect to update properties.
```lua
local create = vide.create
local source = vide.source
local function Counter()
local count = source(0)
return create "TextButton" {
Activated = function()
count(count() + 1)
end,
Text = function()
return "count: " .. count()
end
}
end
```
This example is equivalent to the example seen on the previous page.
Instead of explicitly creating an effect, assigning a (non-event) property a
function will implicitly create an effect to update that property anytime a
source used within is updated.
Just like effects, the function is ran immediately in a reactive scope to set
the property initially and determine what sources are being used.
This allows you as the programmer to not need to manually update UI as the state
of your program changes. You just define how data sources map to UI, and Vide's
reactive system will automatically update any properties depending on those
sources.
## Children Binding
Children can also be set in a similar manner. A source passed as a child (passed
with a number key instead of string key) can return an instance or an array of
instances. Vide will automatically unparent removed instances and parent new
instances when that source's stored instances change.
```lua
local items = source {
create "TextLabel" { Text = "A" }
}
local function List(props: { children: () -> { Instance } })
return create "Frame" {
create "UIListLayout" {},
props.children
}
end
local list = List { children = items } -- creates a list with a single text label "A"
items {
create "TextLabel" { Text = "B" },
create "TextLabel" { Text = "C" }
}
-- this will automatically unparent the text label "A", and parent the labels "B" and "C".
```

View file

@ -0,0 +1,92 @@
# Derived Sources
We have seen the basic way to derive a source:
```lua
local count = source(0)
local text = function()
return "count: " .. tostring(count())
end
print(text()) -- "count: 0"
count(1)
print(text()) -- "count: 1"
```
However, in some cases where this source could be used by multiple effects at
the same time, the function wrapping the source will needlessly rerun to convert
the count into a string for each effect using it.
```lua
local source = vide.source
local effect = vide.effect
local count = source(0)
local text = function()
print "ran"
return "count: " .. tostring(count())
end
effect(function() text() end)
effect(function() text() end)
source(1) -- prints "ran" x2
```
To avoid this, you can use `derive()` to derive a new source instead. This will
run a callback in a new reactive scope only when a dependent source has updated.
Reading this derived source multiple times will just return a cached result from
when it last updated.
```lua
local source = vide.source
local derive = vide.derive
local effect = vide.effect
local count = source(0)
local text = derive(function()
print "ran"
return "count: " .. tostring(count())
end)
effect(function() text() end)
effect(function() text() end)
source(1) -- prints "ran" x1
```
`derive()` must also be called within a reactive scope, just like `effect()`.
If the recalculated value is the same as the old value, the derived source will
not rerun the effects using it.
The reactive graph for the above example:
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1B1B1F",
"primaryTextColor": "#fff",
"primaryBorderColor": "#1B1B1F",
"lineColor": "#79B8FF",
"tertiaryColor": "#161618",
"tertiaryBorderColor": "#161618"
}
}}%%
graph
subgraph root
direction LR
count --> text --> effect1 & effect2
end
```
Deriving a source in this manner is similar to creating an effect to update
another source. You should never manually do this using an effect however,
improper usage could accidently create infinite loops in the reactive graph.
Always favour deriving when you need one source to update based on another.

View file

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

View file

@ -10,14 +10,14 @@ local function is_action(v: any)
end end
local function action(callback: (Instance) -> (), priority: number?): Action local function action(callback: (Instance) -> (), priority: number?): Action
local t = { local a = {
priority = priority or 1, priority = priority or 1,
callback = callback callback = callback
} }
setmetatable(t :: any, ActionMT) setmetatable(a :: any, ActionMT)
return table.freeze(t) return table.freeze(a)
end end
return function() return function()

View file

@ -13,41 +13,58 @@ type Node<T> = graph.Node<T>
type Array<V> = { V } type Array<V> = { V }
type Map<K, V> = { [K]: V } type Map<K, V> = { [K]: V }
-- buffer of event -> callback to connect after properties are set local free_caches: {
local event_buffer = {} :: Map<string, () -> ()> -- event listeners to connect after properties are set
events: Map<
string, -- event name
() -> () -- listener
>,
-- buffer of priority -> callback to run after events are connected -- actions to run after events are connected
local action_buffers = {} :: Map<number, Array<(Instance) -> ()>> actions: Map<
number, -- priority
Array<(Instance) -> ()> -- action callbacks
>,
-- lazily create buffers on nil index -- cache to detect duplicate property setting at same nesting depth
setmetatable(action_buffers :: any, { nested_debug: Map<
__index = function(_, i: number) number, -- depth
action_buffers[i] = {} Map<string, true> -- set of property names
return action_buffers[i] >,
-- use stack instead of recursive function to process nesting layers one at time
-- deeper-nested properties take precedence over shallower-nested ones
-- each nested layer occupies two indexes: 1. table ref 2. nested depth
-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
nested_stack: { {} | number }
}?
local function borrow_caches(): typeof(assert(free_caches))
if free_caches then
local caches = free_caches :: typeof(assert(free_caches))
free_caches = nil
return caches
else
return {
events = {},
actions = setmetatable({} :: any, { -- lazy init
__index = function(self, i) self[i] = {}; return self[i] end
}),
nested_debug = setmetatable({} :: any, {
__index = function(self, i: number) self[i] = {}; return self[i] end
}),
nested_stack = {}
}
end end
}) end
-- cache in strict mode to detect duplicate property set at same nesting level local function return_caches(caches: typeof(free_caches) )
local nested_debug_cache = {} :: Map<number, Map<string, true>> free_caches = caches
end
setmetatable(nested_debug_cache :: any, {
__index = function(_, i: number)
nested_debug_cache[i] = {}
return nested_debug_cache[i]
end
})
-- use stack instead of recursive function to process nested layers one at time
-- deeper-nested properties take precedence over shallower-nested ones
-- each nested layer occupies two indexes: 1. table ref 2. nested depth
-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
local nested_stack = {} :: { {} | number }
-- todo: solution without manual updating of this table
-- map of datatype names to class default constructor for aggregate init -- map of datatype names to class default constructor for aggregate init
local aggregates = {} local aggregates = {}
for name, class in {
for i, v in next, {
CFrame = CFrame, CFrame = CFrame,
Color3 = Color3, Color3 = Color3,
UDim = UDim, UDim = UDim,
@ -55,27 +72,39 @@ for i, v in next, {
Vector2 = Vector2, Vector2 = Vector2,
Vector3 = Vector3, Vector3 = Vector3,
Rect = Rect Rect = Rect
} do } :: Map<string, { [string]: any }> do
aggregates[i] = v.new aggregates[name] = class.new
end end
-- processes a potentially nested table of values to assign to an instance -- applies table of nested properties to an instance using full vide semantics
local function process_props(instance: Instance, properties: Map<unknown, unknown>) local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T
if not properties then
throw("attempt to call a constructor returned by create() with no properties")
end
local strict = flags.strict local strict = flags.strict
table.clear(nested_stack) -- queue parent assignment if any for last
if strict then table.clear(nested_debug_cache) end local parent: unknown = properties.Parent
local caches = borrow_caches()
local events = caches.events
local actions = caches.actions
local nested_debug = caches.nested_debug
local nested_stack = caches.nested_stack
-- process all properties
local depth = 1 local depth = 1
repeat repeat
for property, value in properties do for property, value in properties do
if property == "Parent" then continue end
if type(property) == "string" then if type(property) == "string" then
if strict then -- check for duplicate prop assignment at nesting layer if strict then -- check for duplicate prop assignment at nesting depth
if nested_debug_cache[depth][property] then if nested_debug[depth][property] then
throw(`duplicate property {property} at depth {depth}`) throw(`duplicate property {property} at depth {depth}`)
end end
nested_debug_cache[depth][property] = true nested_debug[depth][property] = true
end end
if type(value) == "table" then -- attempt aggregate init if type(value) == "table" then -- attempt aggregate init
@ -86,7 +115,7 @@ local function process_props(instance: Instance, properties: Map<unknown, unknow
(instance :: any)[property] = ctor(unpack(value :: {})) (instance :: any)[property] = ctor(unpack(value :: {}))
elseif type(value) == "function" then elseif type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> () -- add event to buffer events[property] = value :: () -> () -- add event to buffer
else else
bind.property(instance, property, value :: () -> ()) -- bind property bind.property(instance, property, value :: () -> ()) -- bind property
end end
@ -98,7 +127,7 @@ local function process_props(instance: Instance, properties: Map<unknown, unknow
bind.children(instance, value :: () -> Instance | Array<Instance>) -- bind children bind.children(instance, value :: () -> Instance | Array<Instance>) -- bind children
elseif type(value) == "table" then elseif type(value) == "table" then
if is_action(value) then if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer
else else
table.insert(nested_stack, value :: {}) table.insert(nested_stack, value :: {})
table.insert(nested_stack, depth + 1) -- push table to stack for later processing table.insert(nested_stack, depth + 1) -- push table to stack for later processing
@ -109,36 +138,17 @@ local function process_props(instance: Instance, properties: Map<unknown, unknow
end end
end end
-- pop next nested table off stack
depth = table.remove(nested_stack) :: number depth = table.remove(nested_stack) :: number
properties = table.remove(nested_stack) :: {} properties = table.remove(nested_stack) :: {}
until not properties until not properties
end
-- applies table of nested properties to an instance using full vide semantics for event, listener in next, events do
local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T (instance :: any)[event]:Connect(listener)
-- queue parent assignment if any for last
local parent: unknown = properties.Parent
if parent then properties.Parent = nil end
-- reset buffers
table.clear(event_buffer)
for _, buffer in next, action_buffers do
table.clear(buffer)
end end
-- process all properties for immediate setting or buffering for _, queued in next, actions do
process_props(instance, properties) for _, callback in next, queued do
-- connect buffered events
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
-- run buffered actions
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback(instance) callback(instance)
end end
end end
@ -152,6 +162,14 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
end end
end end
-- clear caches
table.clear(events)
for _, queued in next, actions do table.clear(queued) end
if strict then table.clear(nested_debug) end
table.clear(nested_stack)
return_caches(caches)
return instance return instance
end end

25
src/batch.luau Normal file
View file

@ -0,0 +1,25 @@
if not game then script = require "test/relative-string" end
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local function batch(setter: () -> ())
local already_batching = flags.batch
flags.batch = true
local ok, err: string? = pcall(setter)
if not already_batching then
flags.batch = false
if not already_batching then
graph.flush_update_queue()
end
end
if not ok then throw(`error occured while batching updates: {err}`) end
end
return batch

View file

@ -5,7 +5,7 @@ local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_node = graph.create_node local create_node = graph.create_node
local get_owning_scope = graph.get_owning_scope local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner local set_owner = graph.set_owner
@ -31,8 +31,7 @@ function create_binding<T>(updater: (T) -> T, binding: T)
end end
end end
local owner = assert_owning_scope()
local owner = get_owning_scope()
local node = create_node(binding, updater) local node = create_node(binding, updater)

View file

@ -12,6 +12,8 @@ local function changed<T>(property: string, callback: (T) -> ())
cleanup(function() cleanup(function()
con:Disconnect() con:Disconnect()
end) end)
callback((instance :: any)[property])
end) end)
end end

View file

@ -1,19 +1,43 @@
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
local typeof = game and typeof or require "test/mock".typeof :: never
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local get_scope = graph.get_scope local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup local add_cleanup = graph.add_cleanup
local function cleanup(callback: () -> ()) local function helper(obj: any)
return
if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end
elseif typeof(obj) == "Instance" then function() obj:Destroy() end
elseif obj.destroy then function() obj:destroy() end
elseif obj.disconnect then function() obj:disconnect() end
elseif obj.Destroy then function() obj:Destroy() end
elseif obj.Disconnect then function() obj:Disconnect() end
else throw("cannot cleanup given object")
end
local function cleanup(value: unknown)
local scope = get_scope() local scope = get_scope()
if not scope then if not scope then
throw "cannot cleanup in a non-reactive scope" throw "cannot cleanup in a non-reactive scope"
end; assert(scope) end; assert(scope)
add_cleanup(scope, callback) if type(value) == "function" then
add_cleanup(scope, value :: () -> ())
else
add_cleanup(scope, helper(value))
end
end end
return cleanup type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () }
type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () }
return cleanup ::
( (callback: () -> ()) -> () ) &
( (instance: Destroyable) -> () ) &
( (connection: Disconnectable) -> () ) &
( (instance: Instance) -> () ) &
( (connection: RBXScriptConnection) -> () )

View file

@ -5,41 +5,51 @@ local Instance = game and Instance or require "test/mock".Instance :: never
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local defaults = require(script.Parent.defaults) local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply) local apply = require(script.Parent.apply)
local memoize = require(script.Parent.memoize)
local ctor_cache = {} :: { [string]: () -> Instance }
setmetatable(ctor_cache :: any, {
__index = function(self, class)
local ok, instance: Instance = pcall(Instance.new, class :: any)
if not ok then throw(`invalid class name, could not create instance of class { class }`) end
local default: { [string]: unknown }? = defaults[class]
if default then
for i, v in next, default do
(instance :: any)[i] = v
end
end
local function ctor(properties: Props): Instance
return apply(instance:Clone(), properties)
end
self[class] = ctor
return ctor
end
})
local function create_instance(class: string) local function create_instance(class: string)
local ok, instance: Instance = pcall(Instance.new, class :: any) return ctor_cache[class]
if not ok then throw(`invalid class name, could not create instance of class { class }`) end end
local default: { [string]: unknown }? = defaults[class]
if default then
for i, v in next, default do
(instance :: any)[i] = v
end
end
return function(properties: { [any]: unknown }): Instance
return apply(instance:Clone(), properties)
end
end; create_instance = memoize(create_instance) -- always return same constructor for given class
local function clone_instance(instance: Instance) local function clone_instance(instance: Instance)
return function(properties: { [any]: unknown }): Instance return function(properties: Props): Instance
local clone = instance:Clone() local clone = instance:Clone()
if not clone then error("Attempt to clone a non-archivable instance", 3) end if not clone then throw "attempt to clone a non-archivable instance" end
return apply(clone, properties) return apply(clone, properties)
end end
end end
local function create(class_or_instance: string|Instance) local function create(class_or_instance: string|Instance): (Props) -> Instance
if type(class_or_instance) == "string" then if type(class_or_instance) == "string" then
return create_instance(class_or_instance) return create_instance(class_or_instance)
elseif typeof(class_or_instance) == "Instance" then elseif typeof(class_or_instance) == "Instance" then
return clone_instance(class_or_instance) return clone_instance(class_or_instance)
else else
throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance)) throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance))
return nil :: never
end end
return nil :: never
end end
type Props = { [any]: any } type Props = { [any]: any }

View file

@ -4,11 +4,11 @@ local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local set_owner = graph.set_owner local set_owner = graph.set_owner
local track = graph.track local track = graph.track
local get_owning_scope = graph.get_owning_scope local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local function derive<T>(source: () -> T): () -> T local function derive<T>(source: () -> T): () -> T
local owner = get_owning_scope() local owner = assert_owning_scope()
local node = create_node(false :: any, source) local node = create_node(false :: any, source)

View file

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

View file

@ -1 +1,7 @@
return { strict = false } local function inline_test(): string
return debug.info(1, "n")
end
local is_O2 = inline_test() ~= "inline_test"
return { strict = not is_O2, batch = false }

View file

@ -12,47 +12,44 @@ export type Node<T> = {
cache: T, cache: T,
effect: ((T) -> T) | false, effect: ((T) -> T) | false,
cleanups: { () -> () } | false, cleanups: { () -> () } | false,
parents: { owner: StartNode<T>?, [number]: StartNode<T> },
[number]: Node<T> owned: { Node<T> } | false,
owner: Node<T> | false,
parents: { StartNode<T> },
[number]: Node<T> -- children
} }
-- reactive scope stack -- reactive scope stack
local scopes = { n = 0 } :: { [number]: Node<any>, n: number } local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
-- runs a given callback in a context that Luau does not allow yielding in local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U)
local check_for_yield: <T...>(fn: (T...) -> (), T...) -> (boolean, string?) do local thread = coroutine.create(pcall)
local t = { __mode = "kv" } local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg)
setmetatable(t, t)
check_for_yield = function(fn, ...: any) assert(resume_ok)
local args = { ... }
if coroutine.status(thread) ~= "dead" then
t.__unm = function(_) return false, "attempt to yield in reactive scope"
fn(unpack(args))
end
local ok, err: string? = pcall(function()
local _ = -t
end)
return ok, if err == "attempt to yield across metamethod/C-call boundary"
or err == "thread is not yieldable" then "yield occured"
else err
end end
return run_ok, result
end end
local function get_scope(): Node<unknown>? local function get_scope(): Node<unknown>?
return scopes[scopes.n] return scopes[scopes.n]
end end
local function get_owning_scope(): Node<unknown> local function assert_owning_scope(): Node<unknown>
local scope = get_scope() local scope = get_scope()
if not scope then if not scope then
local caller_name = debug.info(2, "n") local caller_name = debug.info(2, "n")
return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) return throw(`cannot use {caller_name}() in a non-reactive scope`)
elseif scope.effect then elseif scope.effect then
throw("owning scope is not stable; are you trying to derive a new source from within a side-effect?") throw("cannot create new reactive scope in a tracking reactive scope")
end end
return scope return scope
end end
@ -62,8 +59,12 @@ local function add_child<T>(parent: StartNode<any>, child: Node<any>)
end end
local function set_owner(node: Node<any>, owner: Node<any>) local function set_owner(node: Node<any>, owner: Node<any>)
node.parents.owner = owner node.owner = owner
table.insert(owner, node) if owner.owned then
table.insert(owner.owned, node)
else
owner.owned = { node }
end
end end
local function open_scope<T>(node: Node<T>) local function open_scope<T>(node: Node<T>)
@ -96,19 +97,18 @@ local function run_cleanups<T>(node: Node<T>)
end end
end end
local function remove_child<T>(parent: StartNode<T>, child: Node<T>) local function find_and_swap_pop<T>(t: { T }, v: T)
local idx = table.find(parent, child) local idx = table.find(t, v) :: number
assert(idx, "child not found") local n = #t
local n = #parent t[idx] = t[n]
parent[idx] = parent[n] t[n] = nil
parent[n] = nil
end end
local function unparent<T>(node: Node<T>) local function unparent<T>(node: Node<T>)
local parents = node.parents local parents = node.parents
for i, parent in ipairs(parents) do for i, parent in next, parents do
remove_child(parent, node) find_and_swap_pop(parent, node)
parents[i] = nil parents[i] = nil
end end
end end
@ -116,84 +116,121 @@ end
local function destroy<T>(node: Node<T>) local function destroy<T>(node: Node<T>)
run_cleanups(node) run_cleanups(node)
unparent(node) unparent(node)
node.effect = false if node.owner then
find_and_swap_pop(node.owner.owned :: { Node<T> }, node)
if node.parents.owner then node.owner = false
remove_child(node.parents.owner, node)
node.parents.owner = nil
end end
while node[1] do destroy(node[1]) end if node.owned then
local owned = node.owned
while owned[1] do destroy(owned[1]) end
end
end end
local update_queue = {} :: { Node<any> } local function destroy_owned<T>(node: Node<T>)
if node.owned then
local owned = node.owned
while owned[1] do destroy(owned[1]) end
end
end
local update_queue = { n = 0 } :: { n: number, [number]: Node<any> }
local function evaluate_node<T>(node: Node<T>) local function evaluate_node<T>(node: Node<T>)
local cur_value = node.cache local cur_value = node.cache
if flags.strict then if flags.strict then
run_cleanups(node) run_cleanups(node)
destroy_owned(node)
open_scope(node) open_scope(node)
local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) local ok, new_value = ycall(node.effect :: (T) -> T, cur_value)
close_scope() close_scope()
if not ok then throw(err :: string) end if not ok then throw(new_value :: string) end
node.cache = new_value :: T
end end
run_cleanups(node) -- todo: move in scope? run_cleanups(node)
destroy_owned(node)
open_scope(node) open_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) local ok, new_value = pcall(node.effect :: (T) -> T, node.cache)
close_scope() close_scope()
if not ok then if not ok then
table.clear(update_queue) table.clear(update_queue)
update_queue.n = 0
throw(`side-effect error from source update\n{new_value}`) throw(`side-effect error from source update\n{new_value}`)
end end
node.cache = new_value node.cache = new_value
return cur_value ~= new_value -- node has changed value return cur_value ~= new_value
end end
-- todo: case where owner is set from an untrack call within an effectful node, children clearing local function queue_children<T>(node: StartNode<T>)
local function update_from<T>(node: StartNode<T>, n0: number) local i = update_queue.n
if not node[1] then return end while node[1] do
i += 1
update_queue[i] = node[1]
unparent(node[1])
end
update_queue.n = i
end
local n = n0 local _flushing = false
local function flush_update_queue()
assert(not _flushing, "recursive queue flush occured") -- todo
_flushing = true
-- unparent all children and queue for eval local n0 = 0
do
local child = node[1]
while child do -- todo: case where child in owner context
unparent(child)
n += 1 local i = n0 + 1
update_queue[n] = child while i <= update_queue.n do
local node = update_queue[i]
--assert(node.effect)
child = node[1] if node.owner and evaluate_node(node) then
queue_children(node)
end end
update_queue[i] = false :: any
i += 1
end end
-- evaluate all queued children update_queue.n = n0
for i = n0 + 1, n do
local child = update_queue[i] -- todo: error: index boolean
if not child.effect then continue end
if evaluate_node(child) then _flushing = false
update_from(child, n) end
local function update<T>(root: StartNode<T>)
local n0 = update_queue.n
queue_children(root)
if flags.batch then return end
local i = n0 + 1
while i <= update_queue.n do
local node = update_queue[i]
--assert(node.effect)
-- check if node is still owned in case destroyed after queued
if node.owner and evaluate_node(node) then
queue_children(node)
end end
update_queue[i] = false :: any -- false instead of nil to avoid sparse update_queue[i] = false :: any -- false instead of nil to avoid sparse
i += 1
end end
end
local function update<T>(node: StartNode<T>) update_queue.n = n0
update_from(node, 0)
end end
local function track<T>(node: StartNode<T>) local function track<T>(node: StartNode<T>)
@ -208,6 +245,10 @@ local function create_node<T>(value: T, effect: false | (T) -> T): Node<T>
cache = value, cache = value,
effect = effect, effect = effect,
cleanups = false, cleanups = false,
owner = false,
owned = false,
parents = {}, parents = {},
} }
end end
@ -225,7 +266,7 @@ return table.freeze {
close_scope = close_scope, close_scope = close_scope,
evaluate_node = evaluate_node, evaluate_node = evaluate_node,
get_scope = get_scope, get_scope = get_scope,
get_owning_scope = get_owning_scope, assert_owning_scope = assert_owning_scope,
add_cleanup = add_cleanup, add_cleanup = add_cleanup,
set_owner = set_owner, set_owner = set_owner,
destroy = destroy, destroy = destroy,
@ -236,5 +277,6 @@ return table.freeze {
create_node = create_node, create_node = create_node,
create_start_node = create_start_node, create_start_node = create_start_node,
get_children = get_children, get_children = get_children,
flush_update_queue = flush_update_queue,
scopes = scopes scopes = scopes
} }

View file

@ -1,6 +1,6 @@
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- vide.luau -- vide.luau
-- v0.1.0 -- v0.2.0
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
@ -11,10 +11,13 @@ local create = require(script.create)
local apply = require(script.apply) local apply = require(script.apply)
local source = require(script.source) local source = require(script.source)
local effect = require(script.effect) local effect = require(script.effect)
local derive = require(script.derive)
local cleanup = require(script.cleanup) local cleanup = require(script.cleanup)
local untrack = require(script.untrack) local untrack = require(script.untrack)
local derive = require(script.derive) local read = require(script.read)
local batch = require(script.batch)
local switch = require(script.switch) local switch = require(script.switch)
local show = require(script.show)
local indexes, values = require(script.maps)() local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)() local spring, update_springs = require(script.spring)()
local action = require(script.action)() local action = require(script.action)()
@ -51,12 +54,15 @@ local vide = {
effect = effect, effect = effect,
derive = derive, derive = derive,
switch = switch, switch = switch,
show = show,
indexes = indexes, indexes = indexes,
values = values, values = values,
-- util -- util
cleanup = cleanup, cleanup = cleanup,
untrack = untrack, untrack = untrack,
read = read,
batch = batch,
-- animations -- animations
spring = spring, spring = spring,
@ -86,28 +92,22 @@ local vide = {
end end
} }
do setmetatable(vide :: any, {
local set = false __index = function(_, index: unknown): ()
if index == "strict" then
setmetatable(vide :: any, { return flags.strict
__index = function(_, index: unknown): () else
if index == "strict" then throw(`{tostring(index)} is not a valid member of vide`)
return flags.strict
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end,
__newindex = function(_, index: unknown, value: unknown)
if index == "strict" then
if set then throw "strict mode has already been set" end
set = true
flags.strict = value :: boolean
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end end
}) end,
end
__newindex = function(_, index: unknown, value: unknown)
if index == "strict" then
flags.strict = value :: boolean
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end
})
return vide return vide

View file

@ -10,7 +10,7 @@ local create_start_node = graph.create_start_node
local set_owner = graph.set_owner local set_owner = graph.set_owner
local track = graph.track local track = graph.track
local update = graph.update local update = graph.update
local get_owning_scope = graph.get_owning_scope local assert_owning_scope = graph.assert_owning_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
@ -28,7 +28,7 @@ local function check_primitives(t: {})
end end
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO } local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = get_owning_scope() local owner = assert_owning_scope()
local subowner = create_node(false, false) local subowner = create_node(false, false)
set_owner(subowner, owner) set_owner(subowner, owner)
@ -112,6 +112,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
local node = create_node(false :: any, function() local node = create_node(false :: any, function()
return update_children(input()) return update_children(input())
end) end)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
@ -122,7 +123,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
end end
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO } local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = get_owning_scope() local owner = assert_owning_scope()
local subowner = create_node(false, false) local subowner = create_node(false, false)
set_owner(subowner, owner) set_owner(subowner, owner)
@ -214,6 +215,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local node = create_node(false :: any, function() local node = create_node(false :: any, function()
return update_children(input()) return update_children(input())
end) end)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)

View file

@ -1,17 +0,0 @@
local function memoize<X, Y>(f: (X) -> Y): (X) -> Y
local cache: { [X]: Y? } = {}
return function(x: X): Y
local y = cache[x]
if not y then
y = f(x)
cache[x] = y
end
return y :: Y
end
end
return memoize

7
src/read.luau Normal file
View file

@ -0,0 +1,7 @@
if not game then script = require "test/relative-string" end
local function read<T>(value: T | () -> T): T
return if type(value) == "function" then value() else value
end
return read

18
src/show.luau Normal file
View file

@ -0,0 +1,18 @@
if not game then script = require "test/relative-string" end
local switch = require(script.Parent.switch)
local function show<T>(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T?
local function truthy()
return not not source()
end
return switch(truthy) {
[true] = component,
[false] = fallback,
}
end
return show ::
(<T>(source: () -> any, component: () -> T) -> () -> T?) &
(<T, U>(source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?)

View file

@ -6,7 +6,7 @@ local create_start_node = graph.create_start_node
local track = graph.track local track = graph.track
local update = graph.update local update = graph.update
export type Source<T> = (() -> T) & ((T) -> T) export type Source<T> = (() -> T) & ((value: T) -> T)
local function source<T>(initial_value: T): Source<T> local function source<T>(initial_value: T): Source<T>
local node = create_start_node(initial_value) local node = create_start_node(initial_value)

View file

@ -27,7 +27,7 @@ type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T> type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node local create_start_node = graph.create_start_node
local get_owning_scope = graph.get_owning_scope local assert_owning_scope = graph.assert_owning_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local update = graph.update local update = graph.update
local set_owner = graph.set_owner local set_owner = graph.set_owner
@ -150,7 +150,7 @@ local springs: { [SpringData<any>]: StartNode<any> } = {}
setmetatable(springs, { __mode = "v" }) setmetatable(springs, { __mode = "v" })
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T
local owner = get_owning_scope() local owner = assert_owning_scope()
-- https://en.wikipedia.org/wiki/Damping -- https://en.wikipedia.org/wiki/Damping
@ -161,6 +161,12 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
local c_c = 2*w_n local c_c = 2*w_n
local c = z * c_c local c = z * c_c
-- todo: is there a solution other than reducing step size?
-- todo: this does not catch all solver exploding cases
if c > UPDATE_RATE*2 then -- solver will explode if this is true
throw("spring damping too high, consider reducing damping or increasing period")
end
local data: SpringData<T> = { local data: SpringData<T> = {
k = k, k = k,
c = c, c = c,

View file

@ -9,14 +9,14 @@ local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner local set_owner = graph.set_owner
local track = graph.track local track = graph.track
local destroy = graph.destroy local destroy = graph.destroy
local get_owning_scope = graph.get_owning_scope local assert_owning_scope = graph.assert_owning_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
type Map<K, V> = { [K]: V } type Map<K, V> = { [K]: V }
local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U? local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U?
local owner = get_owning_scope() local owner = assert_owning_scope()
return function(map) return function(map)
local last_scope: Node<false>? local last_scope: Node<false>?
@ -35,7 +35,7 @@ local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> ()
if component == nil then return nil end if component == nil then return nil end
if type(component) ~= "function" then if type(component) ~= "function" then
throw("map must map a value to a function") throw "map must map a value to a function"
end end
local new_scope = create_node(false, false) local new_scope = create_node(false, false)
@ -53,7 +53,7 @@ local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> ()
return result return result
end end
local node = create_node(nil :: any, update) local node = create_node(nil :: U?, update)
set_owner(node, owner) set_owner(node, owner)
evaluate_node(node) evaluate_node(node)

View file

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

View file

@ -1,27 +1,27 @@
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local get_scope = graph.get_scope local get_scope = graph.get_scope
local function untrack<T>(source: () -> T): T local function untrack<T>(source: () -> T): T
local scope = get_scope() local scope = get_scope()
if not scope then
throw("cannot untrack in non-reactive scope") if scope then
end; assert(scope) -- sources are only tracked if the node in scope has an effect
local effect = scope.effect
scope.effect = false
-- sources are only tracked if the node in scope has an effect local ok, result = pcall(source)
local effect = scope.effect
scope.effect = false
local ok, result = pcall(source) scope.effect = effect :: () -> ()
scope.effect = effect :: () -> () if not ok then error(result, 0) end
if not ok then error(result, 0) end return result
else
return result return source()
end
end end
return untrack return untrack

View file

@ -6,26 +6,30 @@ local source = vide.source
local derive = vide.derive local derive = vide.derive
local indexes = vide.indexes local indexes = vide.indexes
local values = vide.values local values = vide.values
local batch = vide.batch
local cleanup = vide.cleanup local cleanup = vide.cleanup
local create = vide.create local create = vide.create
assert(not vide.strict)
local function TITLE(name: string) local function TITLE(name: string)
print() print()
print(testkit.color.white(name)) print(testkit.color.white(name))
end end
local N = 2^18 -- 262144 local function ROOT_BENCH(name: string, fn: () -> ())
local function WRAP_BENCH(name: string, fn: () -> ())
vide.root(function(destroy) vide.root(function(destroy)
BENCH(name, fn) BENCH(name, fn)
return destroy return destroy
end)() end)()
end end
local N = 2^18 -- 262144
TITLE "sources" TITLE "sources"
WRAP_BENCH("create source", function() BENCH("create source", function()
local cache = table.create(N) local cache = table.create(N)
for i = 1, START(N) do for i = 1, START(N) do
@ -33,7 +37,7 @@ WRAP_BENCH("create source", function()
end end
end) end)
WRAP_BENCH("get value", function() BENCH("get value", function()
local src = source(1) local src = source(1)
for i = 1, START(N) do for i = 1, START(N) do
@ -41,7 +45,7 @@ WRAP_BENCH("get value", function()
end end
end) end)
WRAP_BENCH("set value", function() BENCH("set value", function()
local src = source(1) local src = source(1)
for i = 1, START(N) do for i = 1, START(N) do
@ -49,7 +53,7 @@ WRAP_BENCH("set value", function()
end end
end) end)
WRAP_BENCH("derive 1 source", function() ROOT_BENCH("derive 1 source", function()
local cache = table.create(N) local cache = table.create(N)
local src = source(1) local src = source(1)
@ -60,7 +64,7 @@ WRAP_BENCH("derive 1 source", function()
end end
end) end)
WRAP_BENCH("derive 4 sources", function() ROOT_BENCH("derive 4 sources", function()
local cache = table.create(N) local cache = table.create(N)
local src = vide.source(1) local src = vide.source(1)
local src2 = vide.source(2) local src2 = vide.source(2)
@ -76,7 +80,7 @@ end)
TITLE "graphs" TITLE "graphs"
WRAP_BENCH("update 1->1 graph", function() ROOT_BENCH("update 1->1 graph", function()
local src = source(1) local src = source(1)
local _derived = derive(function() return src() end) local _derived = derive(function() return src() end)
@ -86,7 +90,7 @@ WRAP_BENCH("update 1->1 graph", function()
end end
end) end)
WRAP_BENCH("update 1->1 graph with cleanup", function() ROOT_BENCH("update 1->1 graph with cleanup", function()
local src = source(1) local src = source(1)
derive(function() derive(function()
@ -99,7 +103,7 @@ WRAP_BENCH("update 1->1 graph with cleanup", function()
end end
end) end)
WRAP_BENCH("update 1->1000 graph", function() ROOT_BENCH("update 1->1000 graph", function()
local src = source(-1) local src = source(-1)
for i = 1, 1000 do for i = 1, 1000 do
@ -113,7 +117,7 @@ WRAP_BENCH("update 1->1000 graph", function()
end end
end) end)
WRAP_BENCH("update 1->1->1->1...1000 graph", function() ROOT_BENCH("update 1->1->1->1...1000 graph", function()
local src = source(-1) local src = source(-1)
local last = src local last = src
@ -129,8 +133,28 @@ WRAP_BENCH("update 1->1->1->1...1000 graph", function()
end end
end) end)
-- todo: repeat with batching -- todo: why does it hang at 1k? it didn't before
WRAP_BENCH("update 1000->1 graph", function() ROOT_BENCH("update 500->1 graph", function()
local srcs = {}
for i = 1, 500 do
srcs[i] = source(0)
end
derive(function()
for i = 1, 500 do
srcs[i]()
end
return false
end)
for i = 1, START(1) do
for idx = 1, 500 do
srcs[idx](i)
end
end
end)
ROOT_BENCH("update 1000->1 graph (batched)", function()
local srcs = {} local srcs = {}
for i = 1, 1000 do for i = 1, 1000 do
srcs[i] = source(0) srcs[i] = source(0)
@ -144,14 +168,16 @@ WRAP_BENCH("update 1000->1 graph", function()
end) end)
for i = 1, START(1) do for i = 1, START(1) do
for idx = 1, 1000 do batch(function()
srcs[idx](i) for idx = 1, 1000 do
end srcs[idx](i)
end
end)
end end
end) end)
-- todo: optimize, repeat with batching -- todo: optimize this case
WRAP_BENCH("update 1000x 1->1 common extern. graph", function() ROOT_BENCH("update 1000 1->1 common extern. graph", function()
local ext = source(-1) local ext = source(-1)
local srcs = {} local srcs = {}
@ -171,7 +197,7 @@ end)
TITLE "property apply" TITLE "property apply"
WRAP_BENCH("apply 0 properties", function() ROOT_BENCH("apply 0 properties", function()
local apply = require "src/apply" local apply = require "src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
@ -180,7 +206,7 @@ WRAP_BENCH("apply 0 properties", function()
end end
end) end)
WRAP_BENCH("apply 8 properties", function() ROOT_BENCH("apply 8 properties", function()
local apply = require "src/apply" local apply = require "src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
@ -198,7 +224,7 @@ WRAP_BENCH("apply 8 properties", function()
end end
end) end)
WRAP_BENCH("bind property", function() ROOT_BENCH("bind property", function()
local apply = require "src/apply" local apply = require "src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
@ -213,7 +239,7 @@ WRAP_BENCH("bind property", function()
return nil return nil
end) end)
WRAP_BENCH("update binding", function() ROOT_BENCH("update binding", function()
local apply = require "src/apply" local apply = require "src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
@ -230,11 +256,29 @@ WRAP_BENCH("update binding", function()
return nil return nil
end) end)
TITLE "switch()"
ROOT_BENCH("switch()", function()
local M = 2^8
local map = {}
for i = 1, M do
map[i] = function() return i end
end
local input = source(0)
vide.switch(input)(map)
for i = 1, START(N) do
input(bit32.band(i, M - 1) + 1) -- i % m + 1
end
end)
TITLE "indexes()" TITLE "indexes()"
N /= 1024 N /= 1024
WRAP_BENCH("indexes() all new", function() ROOT_BENCH("indexes() all new", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -252,7 +296,7 @@ WRAP_BENCH("indexes() all new", function()
return nil return nil
end) end)
WRAP_BENCH("indexes() no change", function() ROOT_BENCH("indexes() no change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -272,7 +316,7 @@ WRAP_BENCH("indexes() no change", function()
return nil return nil
end) end)
WRAP_BENCH("indexes() all change", function() ROOT_BENCH("indexes() all change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -296,7 +340,7 @@ WRAP_BENCH("indexes() all change", function()
src(data) src(data)
end) end)
WRAP_BENCH("indexes() all remove", function() ROOT_BENCH("indexes() all remove", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -320,7 +364,7 @@ end)
TITLE "values()" TITLE "values()"
WRAP_BENCH("values() all new", function() ROOT_BENCH("values() all new", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -338,7 +382,7 @@ WRAP_BENCH("values() all new", function()
return nil return nil
end) end)
WRAP_BENCH("values() no change", function() ROOT_BENCH("values() no change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -358,7 +402,7 @@ WRAP_BENCH("values() no change", function()
src(data) src(data)
end) end)
WRAP_BENCH("values() all change", function() ROOT_BENCH("values() all change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -383,7 +427,7 @@ WRAP_BENCH("values() all change", function()
src(data) src(data)
end) end)
WRAP_BENCH("values() all remove", function() ROOT_BENCH("values() all remove", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -407,7 +451,7 @@ N *= 1024
TITLE "cleanup" TITLE "cleanup"
WRAP_BENCH("register new cleanup", function() ROOT_BENCH("register new cleanup", function()
local cleanup = cleanup local cleanup = cleanup
local cleaner = function() end local cleaner = function() end
@ -431,7 +475,7 @@ TITLE "aggregate"
do do
-- the purpose of the two following benchmarks is to measure the overhead of -- the purpose of the two following benchmarks is to measure the overhead of
-- aggregate construction -- aggregate construction
WRAP_BENCH("set explicit mock vector2", function() ROOT_BENCH("set explicit mock vector2", function()
local apply = require "src/apply" local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2 local Vector2 = require "test/mock".Vector2
@ -446,7 +490,7 @@ do
end end
end) end)
WRAP_BENCH("set aggregate mock vector2", function() ROOT_BENCH("set aggregate mock vector2", function()
local apply = require "src/apply" local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2 local Vector2 = require "test/mock".Vector2
@ -465,7 +509,7 @@ end
-- innacurate due to no Vector3 in vanilla Luau -- innacurate due to no Vector3 in vanilla Luau
-- mock vector is 200x slower than native vector -- mock vector is 200x slower than native vector
-- WRAP_BENCH("spring update", function() -- ROOT_BENCH("spring update", function()
-- local root, source, spring = vide.root, vide.source, vide.spring -- local root, source, spring = vide.root, vide.source, vide.spring
-- local src = source(0) -- local src = source(0)
@ -485,7 +529,7 @@ end
-- N /= 1024 -- N /= 1024
-- WRAP_BENCH("spring step", function() -- ROOT_BENCH("spring step", function()
-- local root, source, spring = vide.root, vide.source, vide.spring -- local root, source, spring = vide.root, vide.source, vide.spring
-- local src = source(0) -- local src = source(0)

View file

@ -48,14 +48,14 @@ local function main()
local reset = "\27[H\27[2J" -- ANSI clear terminal local reset = "\27[H\27[2J" -- ANSI clear terminal
local offset = string.rep("\n", MAX - fv + OFFSET) local offset = string.rep("\n", MAX - fv + OFFSET)
local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv)) local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv))
print(reset .. offset .. bar) print(reset .. offset .. bar .. "\n" .. v)
end) end)
local elapsed = 0 local T = 3
local elapsed = T/1.2
repeat local dt = step() repeat local dt = step()
vide.step(dt) vide.step(dt)
local T = 3
elapsed += dt elapsed += dt
while elapsed >= T do while elapsed >= T do
elapsed -= T elapsed -= T

View file

@ -1,6 +1,6 @@
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- testkit.luau -- testkit.luau
-- v0.7.1 -- v0.7.2
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
local color = { local color = {
@ -323,7 +323,7 @@ end
local function print2(v: unknown) local function print2(v: unknown)
type Buffer = { n: number, [number]: string } type Buffer = { n: number, [number]: string }
type Cyclic = { [{}]: true } type Cyclic = { n: number, [{}]: number }
-- overkill concatenationless string buffer -- overkill concatenationless string buffer
local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic)
@ -347,16 +347,19 @@ local function print2(v: unknown)
else -- is table else -- is table
local tabbed_indent = indent .. TAB local tabbed_indent = indent .. TAB
str.n += 1
if cyclic[value] then if cyclic[value] then
str[str.n] = color.gray "*cyclic reference*" str.n += 1
str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`)
return return
else else
cyclic[value] = true cyclic.n += 1
cyclic[value] = cyclic.n
end end
str[str.n] = "{\n" str.n += 3
str[str.n - 2] = "{ "
str[str.n - 1] = color.gray(tostring(cyclic[value]))
str[str.n - 0] = "\n"
local i, v = next(value, nil) local i, v = next(value, nil)
while v ~= nil do while v ~= nil do
@ -393,7 +396,7 @@ local function print2(v: unknown)
end end
local str = { n = 0 } local str = { n = 0 }
local cyclic = {} local cyclic = { n = 0 }
tos(v, 0, str, cyclic) tos(v, 0, str, cyclic)
print(table.concat(str)) print(table.concat(str))
end end
@ -455,7 +458,7 @@ return {
return BENCH, START return BENCH, START
end, end,
print2 = print2, print = print2,
seq = shallow_eq, seq = shallow_eq,
deq = deep_eq, deq = deep_eq,

View file

@ -31,6 +31,8 @@ end
local NIL = nil :: any local NIL = nil :: any
vide.strict = false
TEST("graph", function() TEST("graph", function()
local create_node = graph.create_node local create_node = graph.create_node
local track = graph.track local track = graph.track
@ -39,6 +41,7 @@ TEST("graph", function()
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local set_owner = graph.set_owner
local get_children = graph.get_children local get_children = graph.get_children
local add_cleanup = graph.add_cleanup local add_cleanup = graph.add_cleanup
local destroy = graph.destroy local destroy = graph.destroy
@ -73,10 +76,14 @@ TEST("graph", function()
end end
do CASE "rerun linked nodes" do CASE "rerun linked nodes"
local root = node()
local a = node() local a = node()
local b = node() local b = node()
local c = node() local c = node()
set_owner(b, root)
set_owner(c, root)
local count = 0 local count = 0
local function effect(x) local function effect(x)
@ -102,8 +109,15 @@ TEST("graph", function()
end end
do CASE "diamond graph" do CASE "diamond graph"
-- a -> b -> d
-- -> c
local root = node()
local a, b, c, d = node(), node(), node(), node() local a, b, c, d = node(), node(), node(), node()
set_owner(b, root)
set_owner(c, root)
set_owner(d, root)
local b_cnt, c_cnt, d_cnt = 0, 0, 0 local b_cnt, c_cnt, d_cnt = 0, 0, 0
function b.effect(x) b_cnt += 1; return not x end function b.effect(x) b_cnt += 1; return not x end
function c.effect(x) c_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end
@ -121,15 +135,20 @@ TEST("graph", function()
end end
do CASE "duplicate child on rerun" do CASE "duplicate child on rerun"
local root = node()
local a, b, c = node(), node(), node() local a, b, c = node(), node(), node()
set_owner(a, root)
set_owner(b, root)
set_owner(c, root)
function c.effect(x) function c.effect(x)
track(a) track(a)
track(b) track(b)
return not x return not x
end end
open_scope(c); assert(c.effect)(NIL); close_scope() open_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); close_scope()
update(a) update(a)
@ -166,28 +185,28 @@ TEST("graph", function()
items_updated = node() items_updated = node()
track(items_updated) -- should not track(items_updated) -- should not
add_child(root, items_updated) set_owner(items_updated, root)
do open_scope(items_updated) do open_scope(items_updated)
track(items) track(items)
do open_scope(root) do open_scope(root)
add_child(root, scope1) set_owner(scope1, root)
do open_scope(scope1) do open_scope(scope1)
clean "scope1" clean "scope1"
bind1 = node() bind1 = node()
add_child(scope1, bind1) set_owner(bind1, scope1)
do open_scope(bind1) do open_scope(bind1)
clean "bind1" clean "bind1"
track(selected) track(selected)
close_scope() end close_scope() end
close_scope() end close_scope() end
add_child(root, scope2) set_owner(scope2, root)
do open_scope(scope2) do open_scope(scope2)
clean "scope2" clean "scope2"
bind2 = node() bind2 = node()
add_child(scope2, bind2) set_owner(bind2, scope2)
do open_scope(bind2) do open_scope(bind2)
clean "bind2" clean "bind2"
track(selected) track(selected)
@ -207,10 +226,10 @@ TEST("graph", function()
do do
local c = get_children(root) local c = get_children(root)
CHECK(#c == 3) CHECK(#c == 0)
CHECK(table.find(c, items_updated)) -- CHECK(table.find(c, items_updated))
CHECK(table.find(c, scope1 :: Node<any>)) -- CHECK(table.find(c, scope1 :: Node<any>))
CHECK(table.find(c, scope2 :: Node<any>)) -- CHECK(table.find(c, scope2 :: Node<any>))
end end
do do
@ -222,19 +241,19 @@ TEST("graph", function()
do do
local c = get_children(scope1) local c = get_children(scope1)
CHECK(#c == 1) CHECK(#c == 0)
CHECK(table.find(c, bind1)) --CHECK(table.find(c, bind1))
end end
do do
local c = get_children(scope2) local c = get_children(scope2)
CHECK(#c == 1) CHECK(#c == 0)
CHECK(table.find(c, bind2)) --CHECK(table.find(c, bind2))
end end
-- destroy -- destroy
CHECK(table.find(get_children(root), scope1 :: Node<any>)) --CHECK(table.find(get_children(root), scope1 :: Node<any>))
destroy(scope1) destroy(scope1)
CHECK(cleaned.scope1) CHECK(cleaned.scope1)
@ -243,7 +262,7 @@ TEST("graph", function()
bind1 = NIL bind1 = NIL
bind2 = NIL bind2 = NIL
gc() gc()
CHECK(#get_children(root) == 2) CHECK(#get_children(root) == 0)
CHECK(#get_children(selected) == 1) CHECK(#get_children(selected) == 1)
end end
@ -253,6 +272,55 @@ TEST("graph", function()
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
end end
do CASE "recursive update"
--[[
A -> B + C
D -> E + F
B updates D
depth=1
B, C
^
depth=2
E, F
^
depth=2
_, F
^
depth=1
_, _ <- attempt to update nothing
^
]]
local a, b, c, d, e, f = node(), node(), node(), node(), node(), node()
local root = node()
set_owner(a, root)
set_owner(b, root)
set_owner(c, root)
set_owner(d, root)
set_owner(e, root)
set_owner(f, root)
function b.effect(x)
update(d)
return not x
end
add_child(a, b); add_child(a, c)
add_child(d, e); add_child(d, f)
update(a)
CHECK(true)
end
end) end)
TEST("mount()", function() TEST("mount()", function()
@ -480,6 +548,44 @@ TEST("derive()", wrap_root(function()
CHECK(count == 2) CHECK(count == 2)
end end
-- do CASE "behavior of effect within an effect"
-- local num = source(1)
-- local ran = table.create(100, 0)
-- local cleaned = table.create(100, 0)
-- local destroy = vide.mount(function()
-- local owner = derive(function()
-- local i = num()
-- return untrack(function()
-- return derive(function()
-- ran[i] += 1
-- cleanup(function()
-- cleaned[i] += 1
-- end)
-- return i
-- end)
-- end)
-- end)
-- local child1 = owner()
-- num(2)
-- CHECK(cleaned[1] == 1)
-- local child2 = owner()
-- CHECK(child1() == 1)
-- CHECK(child2() == 2)
-- end)
-- destroy()
-- CHECK(ran[1] == 1)
-- CHECK(ran[2] == 1)
-- CHECK(cleaned[1] == 1)
-- CHECK(cleaned[2] == 1)
-- end
do CASE "garbage collection" do CASE "garbage collection"
-- check that `b` does not allow gc of `a` -- check that `b` does not allow gc of `a`
local a = source(1) local a = source(1)
@ -554,6 +660,7 @@ TEST("effect()", wrap_root(function()
end)) end))
TEST("cleanup()", wrap_root(function() TEST("cleanup()", wrap_root(function()
local root = vide.root
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
local cleanup = vide.cleanup local cleanup = vide.cleanup
@ -612,6 +719,25 @@ TEST("cleanup()", wrap_root(function()
src(3) src(3)
CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) CHECK(testkit.seq(queue, { 1, 2, 1, 2 }))
end end
do CASE "cleanup objects"
local ran = {}
root(function(destroy)
effect(function()
cleanup { disconnect = function() ran.disconnect = true end }
cleanup { Disconnect = function() ran.Disconnect = true end }
cleanup { destroy = function() ran.destroy = true end }
cleanup { Destroy = function() ran.Destroy = true end }
destroy()
end)
end)
CHECK(ran.disconnect)
CHECK(ran.Disconnect)
CHECK(ran.destroy)
CHECK(ran.Destroy)
end
end)) end))
TEST("create()", wrap_root(function() TEST("create()", wrap_root(function()
@ -816,6 +942,33 @@ TEST("create()", wrap_root(function()
CHECK(not wref[1]) CHECK(not wref[1])
end end
do CASE "recursive create"
local set_test_to_true = vide.action(function(self) (self :: any).test = true end)
local f2
local to_apply = {
{ a = 1 },
set_test_to_true,
b = function() f2 = create "Frame" { a = 2 } end,
} :: { [number|string]: unknown }
-- do -- confirm iteration order
-- local t = {}
-- for i in to_apply do
-- table.insert(t, i)
-- end
-- assert(t[1] == "a")
-- end
local f = create "Frame" (to_apply)
CHECK((f :: any).a == 1)
CHECK((f :: any).test == true )
CHECK((f2 :: any).a == 2)
end
do CASE "garbage collection test" do CASE "garbage collection test"
local wref local wref
@ -843,6 +996,76 @@ TEST("create()", wrap_root(function()
end end
end)) end))
TEST("show()", wrap_root(function()
local untrack = vide.untrack
local cleanup = vide.cleanup
local source = vide.source
local effect = vide.effect
local show = vide.show
local root = vide.root
do CASE "main"
-- uses switch() internally, more extensive testing of scoping not needed
local value = source("truey" :: unknown)
local function one() return 1 end
local function two() return 2 end
local output = show(value, one, two)
CHECK(output() == 1)
value(nil)
CHECK(output() == 2)
end
do CASE "alt"
local visible = vide.source(true)
local count = vide.source(0)
local outer = 0
local inner = 0
local destroyed = 0
root(function()
effect(function()
visible()
outer += 1
untrack(function()
effect(function()
count()
inner += 1
cleanup(function()
destroyed += 1
end)
end)
return nil
end)
end)
end)
CHECK(outer == 1)
CHECK(inner == 1)
CHECK(destroyed == 0)
count(count() + 1)
CHECK(outer == 1)
CHECK(inner == 2)
CHECK(destroyed == 1)
visible(false)
CHECK(outer == 2)
CHECK(inner == 3)
CHECK(destroyed == 2)
count(count() + 1)
CHECK(outer == 2)
CHECK(inner == 4)
CHECK(destroyed == 3)
end
end))
TEST("switch()", wrap_root(function() TEST("switch()", wrap_root(function()
local source = vide.source local source = vide.source
local switch = vide.switch local switch = vide.switch
@ -951,11 +1174,28 @@ TEST("switch()", wrap_root(function()
CHECK(n0 == n1) CHECK(n0 == n1)
end end
do CASE "strict"
vide.strict = true
local input = source(0)
local output = switch(input) {
[0] = function() return 0 end,
[1] = function() return 1 end,
}
CHECK(output() == 0)
input(1)
CHECK(output() == 1)
vide.strict = false
end
end)) end))
TEST("indexes()", wrap_root(function() TEST("indexes()", wrap_root(function()
local create = vide.create local create = vide.create
local source = vide.source local source = vide.source
local effect = vide.effect
local indexes = vide.indexes local indexes = vide.indexes
local cleanup = vide.cleanup local cleanup = vide.cleanup
@ -976,9 +1216,13 @@ TEST("indexes()", wrap_root(function()
local count = table.create(3, 0) local count = table.create(3, 0)
local output = indexes(input, function(v, i) local output = vide.root(function()
count[i] += 1 local output = indexes(input, function(v, i)
return v count[i] += 1
return v
end)
return output
end) end)
input { 1, 2, 4 } input { 1, 2, 4 }
@ -1091,6 +1335,59 @@ TEST("indexes()", wrap_root(function()
CHECK(n0 == n1) CHECK(n0 == n1)
end end
-- practical example based on the graph - recursive update test
do CASE "recursive update"
local items = source { 1 }
local updated = table.create(100, 0)
indexes(items, function(item)
effect(function()
item()
updated[1] += 1
end)
effect(function()
item()
updated[2] += 1
end)
return {}
end)
effect(function()
items()
updated[3] += 1
end)
effect(function()
items()
updated[4] += 1
end)
items { 2 }
CHECK(updated[1] == 2)
CHECK(updated[2] == 2)
CHECK(updated[3] == 2)
CHECK(updated[4] == 2)
end
do CASE "strict"
vide.strict = true
local input = source{1}
local output = indexes(input, function(v)
return { v }
end)
CHECK(output()[1][1]() == 1)
input{2}
CHECK(output()[1][1]() == 2)
vide.strict = false
end
end)) end))
TEST("values()", wrap_root(function() TEST("values()", wrap_root(function()
@ -1321,12 +1618,12 @@ TEST("spring()", wrap_root(function()
end)) end))
TEST("untrack()", wrap_root(function() TEST("untrack()", wrap_root(function()
local root = vide.root
local source = vide.source local source = vide.source
local derive = vide.derive
local effect = vide.effect local effect = vide.effect
local cleanup = vide.cleanup local derive = vide.derive
local untrack = vide.untrack local untrack = vide.untrack
local cleanup = vide.cleanup
local root = vide.root
do CASE "does not register dependency" do CASE "does not register dependency"
local a = source(0) local a = source(0)
@ -1505,7 +1802,7 @@ TEST("changed()", wrap_root(function()
changed("Text", output) changed("Text", output)
} }
--CHECK(output() == "a") CHECK(output() == "a")
text.Text = "b" text.Text = "b"
CHECK(output() == "b") CHECK(output() == "b")
end end
@ -1530,6 +1827,338 @@ TEST("changed()", wrap_root(function()
end end
end)) end))
TEST("batch()", wrap_root(function()
local source = vide.source
local derive = vide.derive
local batch = vide.batch
do CASE "evaluation deferred"
local a = source(0)
local count = { b = 0, b2 = 0, c = 0 }
local b = derive(function()
count.b += 1
return a() + 1
end)
local b2 = derive(function()
count.b2 += 1
return a() + 2
end)
local c = derive(function()
count.c += 1
return b() + b2()
end)
batch(function()
a(1)
CHECK(count.b == 1)
CHECK(count.b2 == 1)
CHECK(count.c == 1)
end)
CHECK(count.b == 2)
CHECK(count.b2 == 2)
CHECK(count.c == 2)
CHECK(b() == 2)
CHECK(c() == 5)
end
do CASE "recursive call"
local a1 = source(0)
local a2 = source(0)
local a3 = source(0)
local count = { b1 = 0, b2 = 0, b3 = 0 }
local b1 = derive(function()
count.b1 += 1
return a1() + 1
end)
local b2 = derive(function()
count.b2 += 1
return a2() + 1
end)
local b3 = derive(function()
count.b3 += 1
return a3() + 1
end)
batch(function()
a1(1)
batch(function()
a2(2)
end)
a3(3)
CHECK(count.b1 == 1)
CHECK(count.b2 == 1)
CHECK(count.b3 == 1)
end)
CHECK(count.b1 == 2)
CHECK(count.b2 == 2)
CHECK(count.b3 == 2)
CHECK(b1() == 2)
CHECK(b2() == 3)
CHECK(b3() == 4)
end
end))
TEST("read()", wrap_root(function()
local source = vide.source
local effect = vide.effect
local read = vide.read :: any -- todo
do CASE "read primitive"
CHECK(read(1) == 1)
end
do CASE "read source"
local src = source(1) :: () -> number
CHECK(read(src) == 1)
end
do CASE "track source"
local src = source(0)
local count = 0
effect(function()
read(src)
count += 1
end)
src(1)
CHECK(count == 2)
end
end))
TEST("nested effects cases", function()
local vide = require "src/init"
local source = vide.source
local effect = vide.effect
local untrack = vide.untrack
local cleanup = vide.cleanup
local root = vide.root
local ran = 0
local cleaned = 0
local function Count()
local count = source(0)
effect(function()
count()
ran += 1
cleanup(function() cleaned += 1 end)
end)
return nil
end
local function App(destroy)
local name = source "a"
effect(function()
name()
untrack(Count)
end)
CHECK(ran == 1)
CHECK(cleaned == 0)
name "b"
CHECK(ran == 2)
CHECK(cleaned == 1)
destroy()
CHECK(ran == 2)
CHECK(cleaned == 2)
end
root(App)
end)
TEST("graph edge cases", wrap_root(function()
local source = vide.source
local derive = vide.derive
local effect = vide.effect
local root = vide.root
do CASE "diamond A,B,C,D"
--[[
a > b > d
> c >
]]
local a = source(0)
local b = derive(function() return (a() % 2 == 0) and 1 or 0 end)
local c = derive(function() return a() * 2 end)
local d = derive(function() return b() + c() end)
local count = { b = 0, c = 0, d = 0 }
effect(function() b(); count.b += 1 end)
effect(function() c(); count.c += 1 end)
effect(function() d(); count.d += 1 end)
a(1)
CHECK(count.b == 2)
CHECK(count.c == 2)
CHECK(count.d == 2)
CHECK(d() == 2)
a(3)
CHECK(count.b == 2)
CHECK(count.c == 3)
CHECK(count.d == 3)
CHECK(d() == 6)
end
do CASE "diamond A,B,C,D,E"
--[[
a > b > e
> c > d >
]]
local a = source(0)
local b = derive(function() return (a() % 2 == 0) and 1 or 0 end)
local c = derive(function() return a() * 2 end)
local d = derive(function() return c() * 2 end)
local e = derive(function() return b() + d() end)
local count = { b = 0, c = 0, d = 0, e = 0 }
effect(function() b(); count.b += 1 end)
effect(function() c(); count.c += 1 end)
effect(function() d(); count.d += 1 end)
effect(function() e(); count.e += 1 end)
CHECK(e() == 1)
a(1)
CHECK(count.b == 2)
CHECK(count.c == 2)
CHECK(count.d == 2)
CHECK(count.e == 3) -- todo: redundant re-eval
CHECK(e() == 4)
a(3)
CHECK(count.b == 2)
CHECK(count.c == 3)
CHECK(count.d == 3)
CHECK(count.e == 4)
CHECK(e() == 12)
end
do CASE "repeated read"
local a = source(0)
local b = derive(function() return a() + a() end)
local count = 0
effect(function() b(); count += 1 end)
a(1)
CHECK(b() == 2)
CHECK(count == 2)
end
do CASE "do not destroy children"
local parent = source(0)
local
destroy,
parent_to_destroy,
update_parent_to_destroy
= root(function(destroy)
local src = source(0)
return
destroy,
derive(function() return src() end),
src
end)
local count = 0
effect(function()
count += 1
parent()
parent_to_destroy()
end)
parent(parent() + 1)
CHECK(count == 2)
update_parent_to_destroy(1)
CHECK(count == 3)
destroy()
update_parent_to_destroy(2)
CHECK(count == 3)
parent(parent() + 1)
CHECK(count == 4)
end
do CASE "double destroy"
-- issue:
-- parent evaluates
-- child A queued
-- child B queued
-- child A destroys child B
-- child B reevaluates due to already being queued
-- parent destroys, destroys child B - uh oh
local
destroy_parent,
parent,
update_parent
= root(function(destroy)
local src = source(0)
return
destroy,
derive(function() return src() end),
src
end)
local destroy_child, _child_B = function() end, nil
local count_A = 0
-- child_A
effect(function()
count_A += 1
parent()
destroy_child()
end)
local count_B = 0
destroy_child, _child_B = root(function(destroy)
return
destroy,
derive(function() count_B += 1; return parent() end)
end)
update_parent(parent() + 1)
CHECK(count_A == 2)
CHECK(count_B == 1) -- child B should not run again
destroy_parent() -- should not error
CHECK(true)
end
end))
TEST("strict", wrap_root(function() TEST("strict", wrap_root(function()
vide.strict = true vide.strict = true
@ -1640,6 +2269,22 @@ TEST("strict", wrap_root(function()
CHECK(ok) CHECK(ok)
end end
do CASE "effect counter"
local src = source(true)
local count = 0
effect(function(x: number)
src()
count = x + 1
return count
end, count)
CHECK(count == 2)
src(not src())
CHECK(count == 4)
end
end)) end))
local ok = FINISH() local ok = FINISH()

14
todo.md
View file

@ -1,14 +1,4 @@
# todo # todo
- auto-enable of strict mode depending on compiler optimizaton level - improve error traces
- property binding optimization - prevent redundant re-eval of nodes in a complex diamond graph
- would no longer allow `cleanup()` usage in binding scopes
- solution to nested reactivity, see: SolidJS stores
- investigate performance of wide graphs
- optimize child removal
- implement from solid:
- Portal
- batch
- optimize `indexes()` double-diffing
- define behavior of deriving a source within a derived source
- review destruction of node under a root that has a child in another root

19
wally.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "centau/vide"
description = "A reactive Luau library for creating UI. "
license = "MIT"
version = "0.2.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
include = ["default.project.json", "LICENSE", "src"]
exclude = [
".github",
"docs",
"test",
".gitattributes",
".gitignore",
".luaurc",
"CHANGELOG.md",
"README.md",
"todo.md"
]