diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d7d4d..83d16e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). -## 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 diff --git a/README.md b/README.md index 2901dbe..8ce077b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,13 @@
- +
-
- -### ⚠️ This library is in early stages of development with breaking changes being made often. - -Vide is a reactive UI library. +Vide is a reactive Luau UI library inspired by [Solid](https://www.solidjs.com/). - Fully Luau typecheckable - Declarative and concise syntax. -- Minimal imports. - Reactively driven. ## Getting started @@ -24,7 +19,6 @@ for a quick introduction to the library. ## Code sample ```lua -local vide = require(path_to_vide) local create = vide.create local source = vide.source diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 519d4d6..87dada1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,7 +1,8 @@ -import { defineConfig } from "vitepress" +//import { defineConfig } from "vitepress" +import { withMermaid } from "vitepress-plugin-mermaid"; // https://vitepress.dev/reference/site-config -export default defineConfig({ +export default withMermaid({ title: "Vide", titleTemplate: ":title - A reactive UI library for Luau", description: "A reactive UI library for Luau.", @@ -16,7 +17,6 @@ export default defineConfig({ { text: "Home", link: "/" }, { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, { text: "API", link: "/api/reactivity-core"}, - { text: "GitHub", link: "https://github.com/centau/vide" } ], sidebar: { @@ -41,34 +41,31 @@ export default defineConfig({ { text: "Introduction", link: "/tut/crash-course/1-introduction" }, { text: "Element Creation", link: "/tut/crash-course/2-creation" }, { text: "Components", link: "/tut/crash-course/3-components" }, - { text: "Source", link: "/tut/crash-course/4-source" }, - { text: "Effect", link: "/tut/crash-course/5-effect" }, - { text: "Derived Source", link: "/tut/crash-course/6-derived-source" }, - { text: "Cleanup", link: "/tut/crash-course/7-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/8-control-flow" }, - { text: "Property Nesting", link: "/tut/crash-course/9-property-nesting" }, - { text: "Actions", link: "/tut/crash-course/10-actions" }, + { text: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Root Scopes", link: "/tut/crash-course/6-root" }, + { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, + { text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, + { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, + { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, + { 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: [ - { text: "switch", link: "/tut/control-flow/switch.md" }, - { text: "indexes", link: "/tut/control-flow/indexes.md" }, - { text: "values", link: "/tut/control-flow/values.md" }, - ] - }, - { - text: "Advanced Reactivity WIP", - items: [ - { text: "reactive-scopes", link: "/tut/reactive-scoping.md"} + { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} ] } ], - } + }, - // socialLinks: [ - // { icon: "github", link: "https://github.com/centau/vide" } - // ] + socialLinks: [ + { icon: "github", link: "https://github.com/centau/vide" } + ] } }) diff --git a/docs/api/creation.md b/docs/api/creation.md index f5b4c1c..8e67478 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -4,7 +4,8 @@ ## 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** @@ -14,7 +15,7 @@ Runs a function and applies its result to a target instance. - **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()`. 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** - - If a table index is a string: - - If its value is a function then it will either bind that property to - the function or connect it if the property type is a `RBXScriptSignal`. - - If the value is not a function then the property will be set to that - value. - - If a table index is a number: - - If its value is an action then that action will be queued to run after - properties are set. - - If its value is a table then that table will be recursively - processed just like the outer table. - - If its value is a function then it will bind the instances children to - that function. - - If its value is an instance then it will be parented to the instance. + - **index is string:** + - **value is function:** + - **property is event:** connect function as callback + - **property is not event:** create effect to update property + - **value is not function:** set property to value + - **index is number:** + - **value is action:** run action + - **value is table:** recurse table + - **value is function:** create effect to update children + - **value is instance:** set instance as child - **Example** @@ -138,9 +136,14 @@ instances. ```lua local function changed(property: string, callback: (new) -> ()) return action(function(instance) - instance:GetPropertyChangedSignal("property"):Connect(function() + local con - instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) + + -- disconnect on reactive scope destruction to allow gc of instance + cleanup(function() + con:Disconnect() + end) end) end @@ -151,3 +154,23 @@ instances. 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. diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 0e4a5d0..133bc4a 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -2,6 +2,10 @@
+:::warning +Yielding is not allowed in any reactive scope. Strict mode can check for this. +::: + ## root() 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** + Returns the result of the given function. + Creates a new root reactive scope, where creation and derivations of sources can be tracked and properly disposed of. - Returns the result of the given function. - A function to destroy the root is passed into the callback, which will run any cleanups and allow derived sources created to garbage collect. - ::: warning - `fn()` cannot yield. - ::: - ## source() Creates a new source with the given value. @@ -44,6 +44,8 @@ Creates a new source with the given value. Reading from the source from within a reactive scope will cause changes to that source to be tracked and anything depending on it to update. + Sources can be created outside of reactive scopes. + - **Example** ```lua @@ -56,7 +58,7 @@ Creates a new source with the given value. ## effect() -Runs a side-effect on source update. +Runs a side-effect in a new reactive scope on source update. - **Type** @@ -66,14 +68,10 @@ Runs a side-effect on source update. - **Details** - The callback is ran immediately. - Any time a source referenced in the callback is changed, the callback will be reran. - ::: warning - `callback()` cannot yield. - ::: + The callback is ran to initially ran on first call to find dependent sources. - **Example** @@ -93,7 +91,7 @@ Runs a side-effect on source update. ## derive() -Derives a new source from existing sources. +Derives a new source in a new reactive scope from existing sources. - **Type** @@ -109,12 +107,7 @@ Derives a new source from existing sources. Anytime its value is recalculated it is also cached, subsequent calls will retun this cached value until it recalculates again. - Takes a callback that is immediately run to determine what sources are being - referenced. - - ::: warning - `source()` cannot yield. - ::: + The callback is ran to initially ran on first call to find dependent sources. - **Example** diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md index 58e2200..47dafa8 100644 --- a/docs/api/reactivity-flow.md +++ b/docs/api/reactivity-flow.md @@ -2,9 +2,33 @@
+## show() + +Shows one of two components depending on an input source. + +- **Type** + + ```lua + function show(source: () -> unknown, component: () -> T): () -> T? + function show(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() -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** @@ -14,12 +38,14 @@ Changes object based on a source and a mapping table. - **Details** - The mapped function is ran in a new reactive scope that is destroyed when - the source changes and maps to a different function. + Returns a source holding an instance of the currently shown component. - ::: warning - Mapped functions cannot yield. - ::: + When the input source changes, the new value will be used to lookup a given + mapping table to get a component, which will be ran under a new reactive + 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** @@ -51,24 +77,26 @@ Maps each index in a table source to an object. - **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 - source table. The first argument is a source containing the index's value - and the second argument is just the index. + source table. - Anytime a new index is added, the transform function will be called again - for that new index. + 1. First argument is a *source containing the index's value*. + 2. Second argument is the *index itself*. - Anytime an existing index value changes, the transform function is not rerun, - instead the source value for that index will update, causing anything + Anytime an existing index's value changes, the transform function is not + rerun, instead the source value for that index will update, causing anything depending on it to update too. - Returns a state containing an array of all objects returned by the - transform. - - ::: warning - `transform()` cannot yield. - ::: - - **Example** The intended purpose of this function is to map each index in a table to @@ -85,14 +113,12 @@ Maps each index in a table source to an object. local displays = indexes(items, function(item, i) return ItemDisplay { Name = function() - return item().name + return i .. ": " .. item().name end, Image = function() return "rbxassetid://" .. item().icon end, - - LayoutOrder = i } end) ``` @@ -111,28 +137,31 @@ Maps each value in a table source to an object. - **Details** - The transform function is called only ever *once* for each value in the - source table. The first argument is the index's value and - the second argument is a source containing the index. + Returns a source holding an array of instances currently shown. - Anytime a new value is added, the transform function will be called again - for that new value. + When the input source changes, each *value* in the new table is compared with + the last input table. Similar to `indexes()` but for values instead of indexes. + + - 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 rerun, instead the source index for that value will update, causing anything depending on it to update too. - Returns a state containing an array of all objects returned by the - transform. - ::: warning - `transform()` cannot yield. - ::: - - ::: warning - Having primitive values in the source table can cause unexpected behavior, - as duplicate primitives can result in multiple index sources being bound - to the same UI element. + 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 + same UI element. Strict mode has checks for this. ::: - **Example** @@ -150,11 +179,11 @@ Maps each value in a table source to an object. local displays = values(items, function(item, i) return ItemDisplay { - Name = item.Name + Name = function() + return i() .. ": " .. item.Name + end Image = "rbxassetid://" .. item.icon, - - LayoutOrder = i } 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. 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. -------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 22adba5..ee82067 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -2,12 +2,17 @@ ## cleanup() -Runs a callback anytime a reactive scope is re-ran. +Runs a callback anytime a reactive scope is reran or destroyed. - **Type** ```lua function cleanup(callback: () -> ()) + function cleanup(obj: Destroyable) + function cleanup(obj: Disconnectable) + + type Destroyable = { destroy: () -> () } + type Disconnectable = { disconnect: () -> () } ``` - **Example** @@ -24,23 +29,10 @@ Runs a callback anytime a reactive scope is re-ran. end) ``` - ```lua - local data = source(1) - - derive(function() - local label = create "TextLabel" { Text = data() } - - cleanup(function() - label:Destroy() - end) - - return label - end) - ``` - ## 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** @@ -70,4 +62,33 @@ Runs a given function where any sources read will not track its reactive scope. 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(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. + -------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 0472954..a7fca48 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -6,6 +6,9 @@ Strict mode is library-wide and can get set by doing: 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 and identifying improper usage. @@ -15,12 +18,13 @@ Currently, strict mode will: 2. Run effects twice when a source updates. 3. Throw an error if yields occur where they are not allowed. 4. Checks for `indexes()` and `values()` returning primitive values. -5. Checks for duplicate nested properties at same depth. -6. Better error reporting and stack traces. -7. Checks for multiple `cleanup()` calls in the same function scope. +5. Checks for `values()` input having duplicate values. +6. Checks for duplicate nested properties at same depth. +7. Better error reporting and stack traces + creation traces of property bindings. -By rerunning sources and effects, any side-effects are made more apparent. -This also helps ensure that cleanups are being handled correctly. +By rerunning derived sources and effects twice each time they update,it helps +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, 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 when binding properties to sources. -It is recommend to develop UI with strict mode and to disable it when pushing to -production. +It is recommended to develop UI with strict mode and to disable it when pushing to +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. diff --git a/docs/package.json b/docs/package.json index 921ee39..5dce993 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { - "vitepress": "^1.0.0-rc.4" + "vitepress": "1.0.0-rc.25", + "vitepress-plugin-mermaid": "2.0.14" } } diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg new file mode 100644 index 0000000..85488e0 --- /dev/null +++ b/docs/public/full_logo.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/logo.svg b/docs/public/logo.svg index 0e64a32..92b06c1 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,4 +1,39 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md new file mode 100644 index 0000000..17c95d2 --- /dev/null +++ b/docs/tut/advanced/nested-scoping.md @@ -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. + + diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md new file mode 100644 index 0000000..8628e45 --- /dev/null +++ b/docs/tut/control-flow/2-show.md @@ -0,0 +1 @@ +# show() diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md new file mode 100644 index 0000000..647835a --- /dev/null +++ b/docs/tut/control-flow/3-switch.md @@ -0,0 +1 @@ +# switch() diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md new file mode 100644 index 0000000..aad2c90 --- /dev/null +++ b/docs/tut/control-flow/4-indexes.md @@ -0,0 +1 @@ +# indexes() diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md new file mode 100644 index 0000000..8909904 --- /dev/null +++ b/docs/tut/control-flow/5-values.md @@ -0,0 +1 @@ +# values() diff --git a/docs/tut/control-flow/indexes.md b/docs/tut/control-flow/indexes.md deleted file mode 100644 index 70864e7..0000000 --- a/docs/tut/control-flow/indexes.md +++ /dev/null @@ -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) - -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. diff --git a/docs/tut/control-flow/switch.md b/docs/tut/control-flow/switch.md deleted file mode 100644 index 70864e7..0000000 --- a/docs/tut/control-flow/switch.md +++ /dev/null @@ -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) - -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. diff --git a/docs/tut/control-flow/values.md b/docs/tut/control-flow/values.md deleted file mode 100644 index 70864e7..0000000 --- a/docs/tut/control-flow/values.md +++ /dev/null @@ -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) - -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. diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 7b00dac..261ce83 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -1,47 +1,25 @@ # Introduction -This is a brief tutorial designed to give you a quick run through the usage of -Vide. +This is a tutorial that introduces the concepts and usage of Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). +This tutorial assumes familiarity with Luau and Roblox UI. + ## Why Vide? -Creating UI is a slow and tedious process. The purpose of Vide is to make UI -declarative and concise, making it faster to create and more importantly easier -to maintain. Vide achieves this using a reactive style of programming which -allows you to focus on the flow of data through your application without -worrying about manually updating UI instances. +Creating UI is complicated, slow, and tedious. + +Vide tries to simplify and speed up this process by providing a declarative and +reactive of style programming, which lets you focus more on designing the UI +itself and not having to manually update or reparent UI instances. Some of the main focuses behind Vide's design choices: -- Concise syntax to reduce verbosity as much as possible. -- Reducing the amount of imports needed for usage by leveraging Luau's syntax - and semantics. -- 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. +- Minimal syntax. +- Complete typechecking +- Independence from instances. -## Structure Of A Vide App - -The entry point for all Vide apps is the `mount()` function. This function -sets up Vide's reactivity system. It takes and calls a function that should -create your entire app, and will apply its result to a target. - -In Vide, your app should be composed of functions, each function creates a -specific part of your app, and can be reused if needed. These functions are -called *components*. - -```lua - -local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "hi" } - } -end - -mount(App, game.StarterGui) -``` +As with most declarative libraries, there is an initial learning curve to +understand the concepts and usage. This tutorial tries to comprehensively +cover these concepts and usage, more so than you need just to use it. diff --git a/docs/tut/crash-course/10-actions.md b/docs/tut/crash-course/10-actions.md deleted file mode 100644 index 68fb06e..0000000 --- a/docs/tut/crash-course/10-actions.md +++ /dev/null @@ -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. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md new file mode 100644 index 0000000..bf632f5 --- /dev/null +++ b/docs/tut/crash-course/10-cleanup.md @@ -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. +::: diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md new file mode 100644 index 0000000..91f95ac --- /dev/null +++ b/docs/tut/crash-course/11-control-flow.md @@ -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 }) + 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. diff --git a/docs/tut/crash-course/9-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md similarity index 94% rename from docs/tut/crash-course/9-property-nesting.md rename to docs/tut/crash-course/12-property-nesting.md index 1a61425..d8c44ce 100644 --- a/docs/tut/crash-course/9-property-nesting.md +++ b/docs/tut/crash-course/12-property-nesting.md @@ -27,7 +27,7 @@ function Menu(props: { Size: UDim2 }) return Background { - Color = props.COlor, + Color = props.Color, AnchorPoint = props.AnchorPoint, Position = props.Position, Size = props.Size @@ -77,7 +77,8 @@ be parented. ```lua type Children = { - Children = Array + -- also can optionally pass a source that returns an array of children too + Children = Array | () -> Array } 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) return create "Frame" { props.Children, - props.Layout, + -- can be overriden by `props.Layout` AnchorPoint = Vector2.new(0.5, 0), Position = UDim2.fromScale(0.5, 0), diff --git a/docs/tut/crash-course/13-actions.md b/docs/tut/crash-course/13-actions.md new file mode 100644 index 0000000..06b431d --- /dev/null +++ b/docs/tut/crash-course/13-actions.md @@ -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. diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/14-strict-mode.md new file mode 100644 index 0000000..ca8465a --- /dev/null +++ b/docs/tut/crash-course/14-strict-mode.md @@ -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). diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md new file mode 100644 index 0000000..62ee35d --- /dev/null +++ b/docs/tut/crash-course/15-concepts.md @@ -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. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index deb1683..96f9dd2 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -6,53 +6,43 @@ Instances are created using `create()`. properties to assign when creating a new instance for that class. Luau allows us to omit parentheses `()` when calling functions with string or -table literals which Vide takes advantage of for brevity. +table literals which is recommended to use for brevity. ```lua -local vide = require(vide) -local mount = vide.mount local create = vide.create -local function App() - return create "ScreenGui" { - create "Frame" { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromScale(0.4, 0.7), +return create "ScreenGui" { + create "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromScale(0.4, 0.7), - create "TextLabel" { - Text = "hi" - }, + create "TextLabel" { + Text = "hi" + }, - create "TextLabel" { - Text = "bye" - }, + create "TextLabel" { + Text = "bye" + }, - create "TextButton" { - Text = "click me", + create "TextButton" { + Text = "click me", - Activated = function() - print "clicked!" - end - } + Activated = function() + print "clicked!" + end } } -end - -mount(App, game.StarterGui) +} ``` Assign a value to a string key to set a property, and assign a value to a number key to set a child. Events can be connected to by assigning a function to a string key. -You can also use a shorthand to create datatypes instead of explicitly typing -out the class name and constructor. The table will be unpacked into the `.new()` -constructor of the property's type. - -```lua -create "Frame" { - AnchorPoint = { 0.5, 1 }, - UDim2 = { 0.5, 0, 0.5, 0 } -} -``` +::: warning +When creating an instance with no properties, it is important to not forget to +actually call the constructor: `create "Frame" {}` and not `create "Frame"`. +To be clear, `create "Frame"` returns a *function* which is a constructor for +that class, not an instance of that class. +::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 712ab9a..aa81916 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,12 +1,16 @@ # 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 -organized. +A component is a function that creates and returns a piece of UI. + +This is a way to separate your UI into small chunks that you can reuse and put +together. + +::: code-group ```lua [Button.luau] -local vide = require(vide) local create = vide.create local function Button(props: { @@ -16,11 +20,14 @@ local function Button(props: { }) return create "TextButton" { BackgroundColor3 = Color3.fromRGB(50, 50, 50), + TextColor3 = Color3.fromRGB(255, 255, 255), Size = UDim2.fromOffset(200, 150), Position = props.Position, Text = props.Text, - Activated = props.Activated + Activated = props.Activated, + + create "UICorner" {} } end @@ -28,8 +35,6 @@ return Button ``` ```lua [App.luau] -local vide = require(vide) -local mount = vide.mount local create = vide.create local Button = require(Button) @@ -38,29 +43,35 @@ local function App() return create "ScreenGui" { Button { Position = UDim2.fromOffset(200, 200), - Text = "click me!", - + Text = "back", 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 -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. -Components allow you to *encapsulate* behavior. You can only modify the -component in ways that you allow in the component. +You can only modify the 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 -is call `Button {}` instead of creating and setting every property each time. -When changing the button in future, any changes to the button file will be -reflected anywhere the button is used throughout your app. - -This can be extended to much more complicated UI. +To create a new button all you must do is call the `Button` function, passing in +values. This saves having to create and set every property each time. Also, when +updating the button component in future, any changes to the button file will be +seen anywhere the button is used in your app. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index cf7ceb2..c356b6b 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,31 +1,14 @@ -# Source +# Sources -*Sources* in Vide are special objects that store a single value. They are the -core of reactivity in Vide, as updates to a source can automatically update -properties or other sources depending on that source. +Sources are special objects that store a single value. They are the core of +Vide's reactivity. They are called sources because they act as sources of data. -A source in Vide can be created using `source()`. +A source can be created using `source()`. ```lua -local vide = require(vide) local source = vide.source -local function Counter() - 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) +local count = source(0) ``` 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 ``` -Each call of `Counter {}` will create a new counter, each maintaining their -own count. +Sources can be *derived* by wrapping them in functions. A wrapped source +effectively becomes a new source. -When you assign a function to a non-event property, Vide will immediately run it -and check what sources were read from. When updating those sources again after, -this function will be re-ran and its return value applied to the property. -This is known as *binding* properties. +```lua +local count = source(0) -This allows you as the programmer to not need to manually update UI as the state -of your program changes. You just define how the data maps to UI, and Vide's -reactive system will automatically update any properties depending on sources -that are updated. +local text = function() + return "count: " .. tostring(count()) +end + +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. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 67c1f58..275a2e6 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,45 +1,51 @@ -# Effect +# Effects -An effect is a function that is run anytime a source updates. They are called -effects because they can produce side-effects when reacting to source changes. +Effects are functions that are ran in response to source updates. They are +called effects because they cause *side-effects* when reacting to source +updates. Effects are created using `effect()`. ```lua -local vide = require(vide) local source = vide.source local effect = vide.effect -local function Counter() - local count = source(0) +local count = source(0) - effect(function() - print("count has updated to: " .. count()) - end) +effect(function() + print("count: " .. count()) +end) - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = count, - - Activated = function() - count(count() + 1) - end - } -end - -mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) +-- "count: 0" printed +count(1) +-- "count: 1" printed ``` -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 -above code sample. The setting of `Text = count` creates another side-effect; -the updating of the Text property anytime the count is changed. +Reactive scopes also track derived sources, it doesn't matter how deeply nested +inside a function a source is. -All observable changes to the user are considered to be side-effects of the -reactive system. +```lua +local source = vide.source +local effect = vide.effect -You should not update other sources using an effect. Improper usage can lead to -unecessary updates and infinite loops. +local count = source(1) + +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. diff --git a/docs/tut/crash-course/6-derived-source.md b/docs/tut/crash-course/6-derived-source.md deleted file mode 100644 index 01fe1c2..0000000 --- a/docs/tut/crash-course/6-derived-source.md +++ /dev/null @@ -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. diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md new file mode 100644 index 0000000..4cb47da --- /dev/null +++ b/docs/tut/crash-course/6-root.md @@ -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. diff --git a/docs/tut/crash-course/7-cleanup.md b/docs/tut/crash-course/7-cleanup.md deleted file mode 100644 index 3e5525d..0000000 --- a/docs/tut/crash-course/7-cleanup.md +++ /dev/null @@ -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. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md new file mode 100644 index 0000000..85139db --- /dev/null +++ b/docs/tut/crash-course/7-stateful-component.md @@ -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. diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/8-control-flow.md deleted file mode 100644 index f590145..0000000 --- a/docs/tut/crash-course/8-control-flow.md +++ /dev/null @@ -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`. diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md new file mode 100644 index 0000000..1fe18fc --- /dev/null +++ b/docs/tut/crash-course/8-property-binding.md @@ -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". +``` diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md new file mode 100644 index 0000000..d3b7156 --- /dev/null +++ b/docs/tut/crash-course/9-derived-source.md @@ -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. diff --git a/docs/tut/reactive-scoping.md b/docs/tut/reactive-scoping.md deleted file mode 100644 index 5b983ab..0000000 --- a/docs/tut/reactive-scoping.md +++ /dev/null @@ -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 diff --git a/src/action.luau b/src/action.luau index f40bf3a..8cc4987 100644 --- a/src/action.luau +++ b/src/action.luau @@ -10,14 +10,14 @@ local function is_action(v: any) end local function action(callback: (Instance) -> (), priority: number?): Action - local t = { + local a = { priority = priority or 1, callback = callback } - setmetatable(t :: any, ActionMT) + setmetatable(a :: any, ActionMT) - return table.freeze(t) + return table.freeze(a) end return function() diff --git a/src/apply.luau b/src/apply.luau index d0bccf7..13e8cab 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -13,41 +13,58 @@ type Node = graph.Node type Array = { V } type Map = { [K]: V } --- buffer of event -> callback to connect after properties are set -local event_buffer = {} :: Map ()> +local free_caches: { + -- event listeners to connect after properties are set + events: Map< + string, -- event name + () -> () -- listener + >, --- buffer of priority -> callback to run after events are connected -local action_buffers = {} :: Map ()>> + -- actions to run after events are connected + actions: Map< + number, -- priority + Array<(Instance) -> ()> -- action callbacks + >, --- lazily create buffers on nil index -setmetatable(action_buffers :: any, { - __index = function(_, i: number) - action_buffers[i] = {} - return action_buffers[i] + -- cache to detect duplicate property setting at same nesting depth + nested_debug: Map< + number, -- depth + Map -- set of property names + >, + + -- 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 --- cache in strict mode to detect duplicate property set at same nesting level -local nested_debug_cache = {} :: Map> +local function return_caches(caches: typeof(free_caches) ) + 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 local aggregates = {} - -for i, v in next, { +for name, class in { CFrame = CFrame, Color3 = Color3, UDim = UDim, @@ -55,27 +72,39 @@ for i, v in next, { Vector2 = Vector2, Vector3 = Vector3, Rect = Rect -} do - aggregates[i] = v.new +} :: Map do + aggregates[name] = class.new end --- processes a potentially nested table of values to assign to an instance -local function process_props(instance: Instance, properties: Map) +-- applies table of nested properties to an instance using full vide semantics +local function apply(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 - table.clear(nested_stack) - if strict then table.clear(nested_debug_cache) end + -- queue parent assignment if any for last + 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 - repeat for property, value in properties do + if property == "Parent" then continue end + if type(property) == "string" then - if strict then -- check for duplicate prop assignment at nesting layer - if nested_debug_cache[depth][property] then + if strict then -- check for duplicate prop assignment at nesting depth + if nested_debug[depth][property] then throw(`duplicate property {property} at depth {depth}`) end - nested_debug_cache[depth][property] = true + nested_debug[depth][property] = true end if type(value) == "table" then -- attempt aggregate init @@ -86,7 +115,7 @@ local function process_props(instance: Instance, properties: Map () -- add event to buffer + events[property] = value :: () -> () -- add event to buffer else bind.property(instance, property, value :: () -> ()) -- bind property end @@ -98,7 +127,7 @@ local function process_props(instance: Instance, properties: Map Instance | Array) -- bind children elseif type(value) == "table" then if is_action(value) then - table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer else table.insert(nested_stack, value :: {}) 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(instance: T & Instance, properties: { [unknown]: unknown }): T - -- 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) + for event, listener in next, events do + (instance :: any)[event]:Connect(listener) end - -- process all properties for immediate setting or buffering - process_props(instance, properties) - - -- 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 + for _, queued in next, actions do + for _, callback in next, queued do callback(instance) end end @@ -152,6 +162,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown 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 end diff --git a/src/batch.luau b/src/batch.luau new file mode 100644 index 0000000..1951789 --- /dev/null +++ b/src/batch.luau @@ -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 diff --git a/src/bind.luau b/src/bind.luau index c614590..4a204f1 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -5,7 +5,7 @@ local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.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 set_owner = graph.set_owner @@ -31,8 +31,7 @@ function create_binding(updater: (T) -> T, binding: T) end end - - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(binding, updater) diff --git a/src/changed.luau b/src/changed.luau index 5d439c6..519a554 100644 --- a/src/changed.luau +++ b/src/changed.luau @@ -12,6 +12,8 @@ local function changed(property: string, callback: (T) -> ()) cleanup(function() con:Disconnect() end) + + callback((instance :: any)[property]) end) end diff --git a/src/cleanup.luau b/src/cleanup.luau index 0bfb6c1..803be03 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,19 +1,43 @@ 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 graph = require(script.Parent.graph) local get_scope = graph.get_scope 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() if not scope then throw "cannot cleanup in a non-reactive 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 -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) -> () ) diff --git a/src/create.luau b/src/create.luau index 0ce889e..7adfc68 100644 --- a/src/create.luau +++ b/src/create.luau @@ -5,41 +5,51 @@ local Instance = game and Instance or require "test/mock".Instance :: never local throw = require(script.Parent.throw) local defaults = require(script.Parent.defaults) 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 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 - - 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 + return ctor_cache[class] +end local function clone_instance(instance: Instance) - return function(properties: { [any]: unknown }): Instance + return function(properties: Props): Instance 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) 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 return create_instance(class_or_instance) elseif typeof(class_or_instance) == "Instance" then return clone_instance(class_or_instance) 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 - return nil :: never end type Props = { [any]: any } diff --git a/src/derive.luau b/src/derive.luau index 49094d5..863fd98 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -4,11 +4,11 @@ local graph = require(script.Parent.graph) local create_node = graph.create_node local set_owner = graph.set_owner 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 function derive(source: () -> T): () -> T - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(false :: any, source) diff --git a/src/effect.luau b/src/effect.luau index 43b12ab..bbe1669 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -2,12 +2,12 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) local create_node = graph.create_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner local function effect(callback: (T) -> T, initial_value: T) - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(initial_value, callback) diff --git a/src/flags.luau b/src/flags.luau index 962301d..cc2d2f8 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -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 } diff --git a/src/graph.luau b/src/graph.luau index 77db497..2908e3d 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -12,47 +12,44 @@ export type Node = { cache: T, effect: ((T) -> T) | false, cleanups: { () -> () } | false, - parents: { owner: StartNode?, [number]: StartNode }, - [number]: Node + + owned: { Node } | false, + owner: Node | false, + + parents: { StartNode }, + [number]: Node -- children } -- reactive scope stack local scopes = { n = 0 } :: { [number]: Node, n: number } --- runs a given callback in a context that Luau does not allow yielding in -local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do - local t = { __mode = "kv" } - setmetatable(t, t) +local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) + local thread = coroutine.create(pcall) + local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) - check_for_yield = function(fn, ...: any) - local args = { ... } - - t.__unm = function(_) - 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 + assert(resume_ok) + + if coroutine.status(thread) ~= "dead" then + return false, "attempt to yield in reactive scope" end + + return run_ok, result end local function get_scope(): Node? return scopes[scopes.n] end -local function get_owning_scope(): Node +local function assert_owning_scope(): Node local scope = get_scope() + if not scope then 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 - 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 + return scope end @@ -62,8 +59,12 @@ local function add_child(parent: StartNode, child: Node) end local function set_owner(node: Node, owner: Node) - node.parents.owner = owner - table.insert(owner, node) + node.owner = owner + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end end local function open_scope(node: Node) @@ -96,19 +97,18 @@ local function run_cleanups(node: Node) end end -local function remove_child(parent: StartNode, child: Node) - local idx = table.find(parent, child) - assert(idx, "child not found") - local n = #parent - parent[idx] = parent[n] - parent[n] = nil +local function find_and_swap_pop(t: { T }, v: T) + local idx = table.find(t, v) :: number + local n = #t + t[idx] = t[n] + t[n] = nil end local function unparent(node: Node) local parents = node.parents - for i, parent in ipairs(parents) do - remove_child(parent, node) + for i, parent in next, parents do + find_and_swap_pop(parent, node) parents[i] = nil end end @@ -116,84 +116,121 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) - - node.effect = false - - if node.parents.owner then - remove_child(node.parents.owner, node) - node.parents.owner = nil + + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) + node.owner = false 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 -local update_queue = {} :: { Node } +local function destroy_owned(node: Node) + 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 } local function evaluate_node(node: Node) local cur_value = node.cache if flags.strict then run_cleanups(node) + destroy_owned(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() - if not ok then throw(err :: string) end + if not ok then throw(new_value :: string) end + + node.cache = new_value :: T end - run_cleanups(node) -- todo: move in scope? + run_cleanups(node) + destroy_owned(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() if not ok then table.clear(update_queue) + update_queue.n = 0 throw(`side-effect error from source update\n{new_value}`) end node.cache = new_value - return cur_value ~= new_value -- node has changed value + return cur_value ~= new_value end --- todo: case where owner is set from an untrack call within an effectful node, children clearing -local function update_from(node: StartNode, n0: number) - if not node[1] then return end +local function queue_children(node: StartNode) + local i = update_queue.n + 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 - do - local child = node[1] - while child do -- todo: case where child in owner context - unparent(child) + local n0 = 0 - n += 1 - update_queue[n] = child + local i = n0 + 1 + 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 + + update_queue[i] = false :: any + i += 1 end - -- evaluate all queued children - for i = n0 + 1, n do - local child = update_queue[i] -- todo: error: index boolean - if not child.effect then continue end + update_queue.n = n0 - if evaluate_node(child) then - update_from(child, n) + _flushing = false +end + +local function update(root: StartNode) + 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 update_queue[i] = false :: any -- false instead of nil to avoid sparse + i += 1 end -end -local function update(node: StartNode) - update_from(node, 0) + update_queue.n = n0 end local function track(node: StartNode) @@ -208,6 +245,10 @@ local function create_node(value: T, effect: false | (T) -> T): Node cache = value, effect = effect, cleanups = false, + + owner = false, + owned = false, + parents = {}, } end @@ -225,7 +266,7 @@ return table.freeze { close_scope = close_scope, evaluate_node = evaluate_node, get_scope = get_scope, - get_owning_scope = get_owning_scope, + assert_owning_scope = assert_owning_scope, add_cleanup = add_cleanup, set_owner = set_owner, destroy = destroy, @@ -236,5 +277,6 @@ return table.freeze { create_node = create_node, create_start_node = create_start_node, get_children = get_children, + flush_update_queue = flush_update_queue, scopes = scopes } diff --git a/src/init.luau b/src/init.luau index 8958dd9..840f7f9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- vide.luau --- v0.1.0 +-- v0.2.0 -------------------------------------------------------------------------------- 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 source = require(script.source) local effect = require(script.effect) +local derive = require(script.derive) local cleanup = require(script.cleanup) 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 show = require(script.show) local indexes, values = require(script.maps)() local spring, update_springs = require(script.spring)() local action = require(script.action)() @@ -51,12 +54,15 @@ local vide = { effect = effect, derive = derive, switch = switch, + show = show, indexes = indexes, values = values, -- util cleanup = cleanup, untrack = untrack, + read = read, + batch = batch, -- animations spring = spring, @@ -86,28 +92,22 @@ local vide = { end } -do - local set = false - - setmetatable(vide :: any, { - __index = function(_, index: unknown): () - if index == "strict" then - 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 +setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if index == "strict" then + return flags.strict + else + throw(`{tostring(index)} is not a valid member of vide`) 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 diff --git a/src/maps.luau b/src/maps.luau index 1649039..ef36209 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -10,7 +10,7 @@ local create_start_node = graph.create_start_node local set_owner = graph.set_owner local track = graph.track 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 close_scope = graph.close_scope local evaluate_node = graph.evaluate_node @@ -28,7 +28,7 @@ local function check_primitives(t: {}) end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = get_owning_scope() + local owner = assert_owning_scope() local subowner = create_node(false, false) set_owner(subowner, owner) @@ -112,6 +112,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, local node = create_node(false :: any, function() return update_children(input()) end) + set_owner(node, owner) evaluate_node(node) @@ -122,7 +123,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = get_owning_scope() + local owner = assert_owning_scope() local subowner = create_node(false, false) set_owner(subowner, owner) @@ -214,6 +215,7 @@ local function values(input: () -> Map, transform: (VI, () -> local node = create_node(false :: any, function() return update_children(input()) end) + set_owner(node, owner) evaluate_node(node) diff --git a/src/memoize.luau b/src/memoize.luau deleted file mode 100644 index cf83427..0000000 --- a/src/memoize.luau +++ /dev/null @@ -1,17 +0,0 @@ -local function memoize(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 - diff --git a/src/read.luau b/src/read.luau new file mode 100644 index 0000000..d3a2fb7 --- /dev/null +++ b/src/read.luau @@ -0,0 +1,7 @@ +if not game then script = require "test/relative-string" end + +local function read(value: T | () -> T): T + return if type(value) == "function" then value() else value +end + +return read diff --git a/src/show.luau b/src/show.luau new file mode 100644 index 0000000..1cf60ca --- /dev/null +++ b/src/show.luau @@ -0,0 +1,18 @@ +if not game then script = require "test/relative-string" end + +local switch = require(script.Parent.switch) + +local function show(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 :: + ((source: () -> any, component: () -> T) -> () -> T?) & + ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) diff --git a/src/source.luau b/src/source.luau index f1307fc..dd633bb 100644 --- a/src/source.luau +++ b/src/source.luau @@ -6,7 +6,7 @@ local create_start_node = graph.create_start_node local track = graph.track local update = graph.update -export type Source = (() -> T) & ((T) -> T) +export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source local node = create_start_node(initial_value) diff --git a/src/spring.luau b/src/spring.luau index 01797ad..cc92d9a 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -27,7 +27,7 @@ type Node = graph.Node type StartNode = graph.StartNode local create_node = graph.create_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 update = graph.update local set_owner = graph.set_owner @@ -150,7 +150,7 @@ local springs: { [SpringData]: StartNode } = {} setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local owner = get_owning_scope() + local owner = assert_owning_scope() -- https://en.wikipedia.org/wiki/Damping @@ -161,6 +161,12 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local c_c = 2*w_n 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 = { k = k, c = c, diff --git a/src/switch.luau b/src/switch.luau index 421d583..12fd376 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -9,14 +9,14 @@ local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner local track = graph.track 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 close_scope = graph.close_scope type Map = { [K]: V } local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = get_owning_scope() + local owner = assert_owning_scope() return function(map) local last_scope: Node? @@ -35,7 +35,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () if component == nil then return nil end if type(component) ~= "function" then - throw("map must map a value to a function") + throw "map must map a value to a function" end local new_scope = create_node(false, false) @@ -53,7 +53,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () return result end - local node = create_node(nil :: any, update) + local node = create_node(nil :: U?, update) set_owner(node, owner) evaluate_node(node) diff --git a/src/throw.luau b/src/throw.luau index d3ea687..70b7973 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end local trace = require(script.Parent.trace) local function throw(msg): any - error(msg, trace()-1) + error(msg, trace() - 1) end return throw diff --git a/src/untrack.luau b/src/untrack.luau index 230674d..86cdb7b 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,27 +1,27 @@ if not game then script = require "test/relative-string" end -local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node local get_scope = graph.get_scope local function untrack(source: () -> T): T local scope = get_scope() - if not scope then - throw("cannot untrack in non-reactive scope") - end; assert(scope) + + if scope then + -- 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 effect = scope.effect - scope.effect = false + local ok, result = pcall(source) - 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 + return result + else + return source() + end end return untrack diff --git a/test/benchmark.luau b/test/benchmark.luau index 6702235..bee8c68 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -6,26 +6,30 @@ local source = vide.source local derive = vide.derive local indexes = vide.indexes local values = vide.values +local batch = vide.batch local cleanup = vide.cleanup local create = vide.create +assert(not vide.strict) + local function TITLE(name: string) print() print(testkit.color.white(name)) end -local N = 2^18 -- 262144 - -local function WRAP_BENCH(name: string, fn: () -> ()) +local function ROOT_BENCH(name: string, fn: () -> ()) vide.root(function(destroy) BENCH(name, fn) return destroy end)() end +local N = 2^18 -- 262144 + + TITLE "sources" -WRAP_BENCH("create source", function() +BENCH("create source", function() local cache = table.create(N) for i = 1, START(N) do @@ -33,7 +37,7 @@ WRAP_BENCH("create source", function() end end) -WRAP_BENCH("get value", function() +BENCH("get value", function() local src = source(1) for i = 1, START(N) do @@ -41,7 +45,7 @@ WRAP_BENCH("get value", function() end end) -WRAP_BENCH("set value", function() +BENCH("set value", function() local src = source(1) for i = 1, START(N) do @@ -49,7 +53,7 @@ WRAP_BENCH("set value", function() end end) -WRAP_BENCH("derive 1 source", function() +ROOT_BENCH("derive 1 source", function() local cache = table.create(N) local src = source(1) @@ -60,7 +64,7 @@ WRAP_BENCH("derive 1 source", function() end end) -WRAP_BENCH("derive 4 sources", function() +ROOT_BENCH("derive 4 sources", function() local cache = table.create(N) local src = vide.source(1) local src2 = vide.source(2) @@ -76,7 +80,7 @@ end) TITLE "graphs" -WRAP_BENCH("update 1->1 graph", function() +ROOT_BENCH("update 1->1 graph", function() local src = source(1) local _derived = derive(function() return src() end) @@ -86,7 +90,7 @@ WRAP_BENCH("update 1->1 graph", function() end end) -WRAP_BENCH("update 1->1 graph with cleanup", function() +ROOT_BENCH("update 1->1 graph with cleanup", function() local src = source(1) derive(function() @@ -99,7 +103,7 @@ WRAP_BENCH("update 1->1 graph with cleanup", function() end end) -WRAP_BENCH("update 1->1000 graph", function() +ROOT_BENCH("update 1->1000 graph", function() local src = source(-1) for i = 1, 1000 do @@ -113,7 +117,7 @@ WRAP_BENCH("update 1->1000 graph", function() 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 last = src @@ -129,8 +133,28 @@ WRAP_BENCH("update 1->1->1->1...1000 graph", function() end end) --- todo: repeat with batching -WRAP_BENCH("update 1000->1 graph", function() +-- todo: why does it hang at 1k? it didn't before +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 = {} for i = 1, 1000 do srcs[i] = source(0) @@ -144,14 +168,16 @@ WRAP_BENCH("update 1000->1 graph", function() end) for i = 1, START(1) do - for idx = 1, 1000 do - srcs[idx](i) - end + batch(function() + for idx = 1, 1000 do + srcs[idx](i) + end + end) end end) --- todo: optimize, repeat with batching -WRAP_BENCH("update 1000x 1->1 common extern. graph", function() +-- todo: optimize this case +ROOT_BENCH("update 1000 1->1 common extern. graph", function() local ext = source(-1) local srcs = {} @@ -171,7 +197,7 @@ end) TITLE "property apply" -WRAP_BENCH("apply 0 properties", function() +ROOT_BENCH("apply 0 properties", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -180,7 +206,7 @@ WRAP_BENCH("apply 0 properties", function() end end) -WRAP_BENCH("apply 8 properties", function() +ROOT_BENCH("apply 8 properties", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -198,7 +224,7 @@ WRAP_BENCH("apply 8 properties", function() end end) -WRAP_BENCH("bind property", function() +ROOT_BENCH("bind property", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -213,7 +239,7 @@ WRAP_BENCH("bind property", function() return nil end) -WRAP_BENCH("update binding", function() +ROOT_BENCH("update binding", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -230,11 +256,29 @@ WRAP_BENCH("update binding", function() return nil 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()" N /= 1024 -WRAP_BENCH("indexes() all new", function() +ROOT_BENCH("indexes() all new", function() local data = {} for i = 1, N do @@ -252,7 +296,7 @@ WRAP_BENCH("indexes() all new", function() return nil end) -WRAP_BENCH("indexes() no change", function() +ROOT_BENCH("indexes() no change", function() local data = {} for i = 1, N do @@ -272,7 +316,7 @@ WRAP_BENCH("indexes() no change", function() return nil end) -WRAP_BENCH("indexes() all change", function() +ROOT_BENCH("indexes() all change", function() local data = {} for i = 1, N do @@ -296,7 +340,7 @@ WRAP_BENCH("indexes() all change", function() src(data) end) -WRAP_BENCH("indexes() all remove", function() +ROOT_BENCH("indexes() all remove", function() local data = {} for i = 1, N do @@ -320,7 +364,7 @@ end) TITLE "values()" -WRAP_BENCH("values() all new", function() +ROOT_BENCH("values() all new", function() local data = {} for i = 1, N do @@ -338,7 +382,7 @@ WRAP_BENCH("values() all new", function() return nil end) -WRAP_BENCH("values() no change", function() +ROOT_BENCH("values() no change", function() local data = {} for i = 1, N do @@ -358,7 +402,7 @@ WRAP_BENCH("values() no change", function() src(data) end) -WRAP_BENCH("values() all change", function() +ROOT_BENCH("values() all change", function() local data = {} for i = 1, N do @@ -383,7 +427,7 @@ WRAP_BENCH("values() all change", function() src(data) end) -WRAP_BENCH("values() all remove", function() +ROOT_BENCH("values() all remove", function() local data = {} for i = 1, N do @@ -407,7 +451,7 @@ N *= 1024 TITLE "cleanup" -WRAP_BENCH("register new cleanup", function() +ROOT_BENCH("register new cleanup", function() local cleanup = cleanup local cleaner = function() end @@ -431,7 +475,7 @@ TITLE "aggregate" do -- the purpose of the two following benchmarks is to measure the overhead of -- aggregate construction - WRAP_BENCH("set explicit mock vector2", function() + ROOT_BENCH("set explicit mock vector2", function() local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -446,7 +490,7 @@ do end end) - WRAP_BENCH("set aggregate mock vector2", function() + ROOT_BENCH("set aggregate mock vector2", function() local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -465,7 +509,7 @@ end -- innacurate due to no Vector3 in vanilla Luau -- 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 src = source(0) @@ -485,7 +529,7 @@ end -- N /= 1024 --- WRAP_BENCH("spring step", function() +-- ROOT_BENCH("spring step", function() -- local root, source, spring = vide.root, vide.source, vide.spring -- local src = source(0) diff --git a/test/spring-test.luau b/test/spring-test.luau index c22d955..2e3bc9b 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -48,14 +48,14 @@ local function main() local reset = "\27[H\27[2J" -- ANSI clear terminal local offset = string.rep("\n", MAX - fv + OFFSET) 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) - local elapsed = 0 + local T = 3 + local elapsed = T/1.2 repeat local dt = step() vide.step(dt) - local T = 3 elapsed += dt while elapsed >= T do elapsed -= T diff --git a/test/testkit.luau b/test/testkit.luau index 544c7cc..31876eb 100644 --- a/test/testkit.luau +++ b/test/testkit.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- testkit.luau --- v0.7.1 +-- v0.7.2 -------------------------------------------------------------------------------- local color = { @@ -323,7 +323,7 @@ end local function print2(v: unknown) type Buffer = { n: number, [number]: string } - type Cyclic = { [{}]: true } + type Cyclic = { n: number, [{}]: number } -- overkill concatenationless string buffer local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) @@ -347,16 +347,19 @@ local function print2(v: unknown) else -- is table local tabbed_indent = indent .. TAB - str.n += 1 - if cyclic[value] then - str[str.n] = color.gray "*cyclic reference*" + str.n += 1 + str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`) return else - cyclic[value] = true + cyclic.n += 1 + cyclic[value] = cyclic.n 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) while v ~= nil do @@ -393,7 +396,7 @@ local function print2(v: unknown) end local str = { n = 0 } - local cyclic = {} + local cyclic = { n = 0 } tos(v, 0, str, cyclic) print(table.concat(str)) end @@ -455,7 +458,7 @@ return { return BENCH, START end, - print2 = print2, + print = print2, seq = shallow_eq, deq = deep_eq, diff --git a/test/tests.luau b/test/tests.luau index c8844f8..c38c6a7 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -31,6 +31,8 @@ end local NIL = nil :: any +vide.strict = false + TEST("graph", function() local create_node = graph.create_node local track = graph.track @@ -39,6 +41,7 @@ TEST("graph", function() local get_scope = graph.get_scope local open_scope = graph.open_scope local close_scope = graph.close_scope + local set_owner = graph.set_owner local get_children = graph.get_children local add_cleanup = graph.add_cleanup local destroy = graph.destroy @@ -73,10 +76,14 @@ TEST("graph", function() end do CASE "rerun linked nodes" + local root = node() local a = node() local b = node() local c = node() + set_owner(b, root) + set_owner(c, root) + local count = 0 local function effect(x) @@ -102,8 +109,15 @@ TEST("graph", function() end do CASE "diamond graph" + -- a -> b -> d + -- -> c + local root = node() local a, b, c, d = node(), node(), node(), node() + set_owner(b, root) + set_owner(c, root) + set_owner(d, root) + local b_cnt, c_cnt, d_cnt = 0, 0, 0 function b.effect(x) b_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end @@ -121,15 +135,20 @@ TEST("graph", function() end do CASE "duplicate child on rerun" + local root = node() local a, b, c = node(), node(), node() + set_owner(a, root) + set_owner(b, root) + set_owner(c, root) + function c.effect(x) track(a) track(b) return not x 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) @@ -166,28 +185,28 @@ TEST("graph", function() items_updated = node() track(items_updated) -- should not - add_child(root, items_updated) + set_owner(items_updated, root) do open_scope(items_updated) track(items) do open_scope(root) - add_child(root, scope1) + set_owner(scope1, root) do open_scope(scope1) clean "scope1" bind1 = node() - add_child(scope1, bind1) + set_owner(bind1, scope1) do open_scope(bind1) clean "bind1" track(selected) close_scope() end close_scope() end - add_child(root, scope2) + set_owner(scope2, root) do open_scope(scope2) clean "scope2" bind2 = node() - add_child(scope2, bind2) + set_owner(bind2, scope2) do open_scope(bind2) clean "bind2" track(selected) @@ -207,10 +226,10 @@ TEST("graph", function() do local c = get_children(root) - CHECK(#c == 3) - CHECK(table.find(c, items_updated)) - CHECK(table.find(c, scope1 :: Node)) - CHECK(table.find(c, scope2 :: Node)) + CHECK(#c == 0) + -- CHECK(table.find(c, items_updated)) + -- CHECK(table.find(c, scope1 :: Node)) + -- CHECK(table.find(c, scope2 :: Node)) end do @@ -222,19 +241,19 @@ TEST("graph", function() do local c = get_children(scope1) - CHECK(#c == 1) - CHECK(table.find(c, bind1)) + CHECK(#c == 0) + --CHECK(table.find(c, bind1)) end do local c = get_children(scope2) - CHECK(#c == 1) - CHECK(table.find(c, bind2)) + CHECK(#c == 0) + --CHECK(table.find(c, bind2)) end -- destroy - CHECK(table.find(get_children(root), scope1 :: Node)) + --CHECK(table.find(get_children(root), scope1 :: Node)) destroy(scope1) CHECK(cleaned.scope1) @@ -243,7 +262,7 @@ TEST("graph", function() bind1 = NIL bind2 = NIL gc() - CHECK(#get_children(root) == 2) + CHECK(#get_children(root) == 0) CHECK(#get_children(selected) == 1) end @@ -253,6 +272,55 @@ TEST("graph", function() gc() CHECK(not wref[1]) 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) TEST("mount()", function() @@ -480,6 +548,44 @@ TEST("derive()", wrap_root(function() CHECK(count == 2) 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" -- check that `b` does not allow gc of `a` local a = source(1) @@ -554,6 +660,7 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() + local root = vide.root local source = vide.source local effect = vide.effect local cleanup = vide.cleanup @@ -612,6 +719,25 @@ TEST("cleanup()", wrap_root(function() src(3) CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) 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)) TEST("create()", wrap_root(function() @@ -816,6 +942,33 @@ TEST("create()", wrap_root(function() CHECK(not wref[1]) 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" local wref @@ -843,6 +996,76 @@ TEST("create()", wrap_root(function() 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() local source = vide.source local switch = vide.switch @@ -951,11 +1174,28 @@ TEST("switch()", wrap_root(function() CHECK(n0 == n1) 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)) TEST("indexes()", wrap_root(function() local create = vide.create local source = vide.source + local effect = vide.effect local indexes = vide.indexes local cleanup = vide.cleanup @@ -976,9 +1216,13 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local output = indexes(input, function(v, i) - count[i] += 1 - return v + local output = vide.root(function() + local output = indexes(input, function(v, i) + count[i] += 1 + return v + end) + + return output end) input { 1, 2, 4 } @@ -1091,6 +1335,59 @@ TEST("indexes()", wrap_root(function() CHECK(n0 == n1) 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)) TEST("values()", wrap_root(function() @@ -1321,12 +1618,12 @@ TEST("spring()", wrap_root(function() end)) TEST("untrack()", wrap_root(function() - local root = vide.root local source = vide.source - local derive = vide.derive local effect = vide.effect - local cleanup = vide.cleanup + local derive = vide.derive local untrack = vide.untrack + local cleanup = vide.cleanup + local root = vide.root do CASE "does not register dependency" local a = source(0) @@ -1505,7 +1802,7 @@ TEST("changed()", wrap_root(function() changed("Text", output) } - --CHECK(output() == "a") + CHECK(output() == "a") text.Text = "b" CHECK(output() == "b") end @@ -1530,6 +1827,338 @@ TEST("changed()", wrap_root(function() 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() vide.strict = true @@ -1640,6 +2269,22 @@ TEST("strict", wrap_root(function() CHECK(ok) 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)) local ok = FINISH() diff --git a/todo.md b/todo.md index 9fb48b6..6f254bd 100644 --- a/todo.md +++ b/todo.md @@ -1,14 +1,4 @@ # todo -- auto-enable of strict mode depending on compiler optimizaton level -- property binding optimization - - 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 +- improve error traces +- prevent redundant re-eval of nodes in a complex diamond graph diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..b1387e5 --- /dev/null +++ b/wally.toml @@ -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" +]