From d77fe0f92f563eb46ffd8d65def3c5ab4c2e351e Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 14 Sep 2023 18:15:54 +0100 Subject: [PATCH] --- docs/.vitepress/config.ts | 23 +- docs/api/creation.md | 47 ++- docs/api/reactivity-core.md | 169 +--------- docs/api/reactivity-flow.md | 186 +++++++++++ docs/api/reactivity-utility.md | 2 + docs/api/strict-mode.md | 4 +- docs/tut/control-flow/indexes.md | 66 ++++ docs/tut/control-flow/switch.md | 66 ++++ docs/tut/control-flow/values.md | 66 ++++ docs/tut/crash-course/1-introduction.md | 8 +- docs/tut/crash-course/5-effect.md | 3 + docs/tut/crash-course/8-control-flow.md | 117 ++++--- docs/tut/reactive-scoping.md | 61 ++++ src/action.luau | 2 +- src/bind.luau | 26 +- src/cleanup.luau | 9 +- src/create.luau | 8 +- src/defaults.luau | 3 +- src/derive.luau | 4 +- src/effect.luau | 6 +- src/graph.luau | 41 ++- src/maps.luau | 28 +- src/root.luau | 2 +- src/source.luau | 2 - src/spring.luau | 75 ++--- src/switch.luau | 19 +- src/throw.luau | 31 +- src/trace.luau | 29 ++ src/untrack.luau | 12 +- test/benchmark.luau | 237 +++++++++++--- test/relative-string.luau | 3 +- test/spring-test.luau | 2 +- test/tests.luau | 416 ++++++++++++++++-------- todo.md | 8 +- 34 files changed, 1215 insertions(+), 566 deletions(-) create mode 100644 docs/tut/control-flow/indexes.md create mode 100644 docs/tut/control-flow/switch.md create mode 100644 docs/tut/control-flow/values.md create mode 100644 docs/tut/reactive-scoping.md create mode 100644 src/trace.luau diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d1ae594..519d4d6 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -26,6 +26,7 @@ export default defineConfig({ items: [ { text: "Reactivity: Core", link: "/api/reactivity-core" }, { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, + { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, { text: "Element Creation", link: "/api/creation" }, { text: "Animation", link: "/api/animation" }, { text: "Strict Mode", link: "/api/strict-mode" }, @@ -41,16 +42,26 @@ export default defineConfig({ { text: "Element Creation", link: "/tut/crash-course/2-creation" }, { text: "Components", link: "/tut/crash-course/3-components" }, { text: "Source", link: "/tut/crash-course/4-source" }, - { text: "Derived Source", link: "/tut/crash-course/5-derived-source" }, - { text: "Table Source", link: "/tut/crash-course/6-table-source" }, - { text: "Nested Properties", link: "/tut/crash-course/7-nested-properties" }, - { text: "Actions", link: "/tut/crash-course/8-actions" }, + { text: "Effect", link: "/tut/crash-course/5-effect" }, + { text: "Derived Source", link: "/tut/crash-course/6-derived-source" }, + { text: "Cleanup", link: "/tut/crash-course/7-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/8-control-flow" }, + { text: "Property Nesting", link: "/tut/crash-course/9-property-nesting" }, + { text: "Actions", link: "/tut/crash-course/10-actions" }, ] }, { - text: "Tutorials", + text: "Control Flow WIP", items: [ - { text: "Crash Course", link: "/tut/crash-course/index" }, + { text: "switch", link: "/tut/control-flow/switch.md" }, + { text: "indexes", link: "/tut/control-flow/indexes.md" }, + { text: "values", link: "/tut/control-flow/values.md" }, + ] + }, + { + text: "Advanced Reactivity WIP", + items: [ + { text: "reactive-scopes", link: "/tut/reactive-scoping.md"} ] } ], diff --git a/docs/api/creation.md b/docs/api/creation.md index 8726919..f5b4c1c 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -2,6 +2,38 @@
+## mount() + +Runs a function and applies its result to a target instance. + +- **Type** + + ```lua + function mount(component: () -> T, target: Instance?): () -> () + ``` + +- **Details** + + The result of the function is applies to the target in the same way + properties are using `create()`. + + The function is ran in a new reactive scope, just like + [root()](reactivity-core.md#root). + + Returns a function that when called will destroy the reactive scope. + +- **Example** + + ```lua + local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "Vide" } + } + end + + mount(App, game.StarterGui) + ``` + ## create() Creates a new UI element, applying any given properties. @@ -28,19 +60,18 @@ Creates a new UI element, applying any given properties. - **Property setting rules** - - If a table index is a string: - - If its value is a table then it will attempt to perform aggregate - initialization. - If its value is a function then it will either bind that property to the function or connect it if the property type is a `RBXScriptSignal`. - If the value is not a function then the property will be set to that value. - If a table index is a number: - - If its value is a table then that table will be recursively - - processed just like the outer table. - - If its value is a function then it will parent and bind any instances - returned by that function as children. + - If its value is an action then that action will be queued to run after + properties are set. + - If its value is a table then that table will be recursively + processed just like the outer table. + - If its value is a function then it will bind the instances children to + that function. - If its value is an instance then it will be parented to the instance. - **Example** @@ -54,7 +85,7 @@ Creates a new UI element, applying any given properties. } ``` - A component using property nesting/grouping. + A component using property nesting. ```lua type Layout = { diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 9585184..0e056f9 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -22,6 +22,10 @@ Creates and runs a function in a new reactive scope. Also returns a function to destroy the root, 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. @@ -52,42 +56,37 @@ Creates a new source with the given value. ## effect() -Runs a callback on source update. +Runs a side-effect on source update. - **Type** ```lua - function effect(source: () -> ()): Uneffect - - type Uneffect = () -> () + function effect(callback: () -> ()) ``` - **Details** - The source callback is ran immediately to determine what states are - referenced. + The callback is ran immediately. Any time a source referenced in the callback is changed, the callback will be reran. - Also returns a function that when called, stops the effecter immediately. - ::: warning - `source()` cannot yield. + `callback()` cannot yield. ::: - **Example** ```lua - local state = source(1) + local num = source(1) effect(function() - print(state()) + print(num()) end) -- prints 1 - state(state() + 1) + num(num() + 1) -- prints 2 ``` @@ -130,150 +129,4 @@ Derives a new source from existing sources. text() -- "count: 1" ``` -## indexes() - -Maps each index in a table source to an object. - -- **Type** - - ```lua - function indexes( - source: () -> Map, - transform: (value: () -> VI, index: KI) -> VO - ): Array - -- **Details** - - The transform function is called only ever *once* for each index in the - source table. The first argument is a source containing the index's value - and the second argument is just the index. - - Anytime a new index is added, the transform function will be called again - for that new index. - - Anytime an existing index value changes, the transform function is not rerun, - instead the source value for that index will update, causing anything - depending on it to update too. - - Returns a state containing an array of all objects returned by the - transform. - - ::: warning - `transform()` cannot yield. - ::: - -- **Example** - - The intended purpose of this function is to map each index in a table to - a UI element. - - ```lua - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = indexes(items, function(item, i) - return ItemDisplay { - Name = function() - return item().name - end, - - Image = function() - return "rbxassetid://" .. item().icon - end, - - LayoutOrder = i - } - end) - ``` - -## values() - -Maps each value in a table source to an object. - -- **Type** - - ```lua - function values( - source: () -> Map, - transform: (value: VI, index: () -> KI) -> VO - ): Array - -- **Details** - - The transform function is called only ever *once* for each value in the - source table. The first argument is the index's value and - the second argument is a source containing the index. - - Anytime a new value is added, the transform function will be called again - for that new value. - - Anytime an existing value's index changes, the transform function is not - rerun, instead the source index for that value will update, causing anything - depending on it to update too. - - Returns a state containing an array of all objects returned by the - transform. - - ::: warning - `transform()` cannot yield. - ::: - - ::: warning - Having primitive values in the source table can cause unexpected behavior, - as duplicate primitives can result in multiple index sources being bound - to the same UI element. - ::: - -- **Example** - - The intended purpose of this function is to map each value in a table to - a UI element. - - ```lua - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = values(items, function(item, i) - return ItemDisplay { - Name = item.Name - - Image = "rbxassetid://" .. item.icon, - - LayoutOrder = i - } - end) - ``` - -- **Extra** - - When should you use `indexes()` and `values()`? - - `values()` should be used when you have a fixed set of objects where the - same objects can be re-arranged in the source table. It maps a value to a - UI element. - - e.g. - - List of all players. - - Inventory of items. - - Chat message history. - - Toast notifications. - - `indexes()` should be used in other cases, especially when your source table - has primitive value. It maps an index to a UI element. - - e.g. - - List of character or weapon stats. - - In most cases, both functions will appear to have the same behavior. - The main difference is performance, picking the right function to use can - result in less property updates and less re-renders. - -------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md index e69de29..58e2200 100644 --- a/docs/api/reactivity-flow.md +++ b/docs/api/reactivity-flow.md @@ -0,0 +1,186 @@ +# Reactivity API: Control Flow + +
+ +## switch() + +Changes object based on a source and a mapping table. + +- **Type** + + ```lua + function switch(source: () -> K): (map: Map V>) -> V? + ``` + +- **Details** + + The mapped function is ran in a new reactive scope that is destroyed when + the source changes and maps to a different function. + + ::: warning + Mapped functions cannot yield. + ::: + +- **Example** + + ```lua + local logged = source(false) + + local button = switch(logged) { + [true] = function() + return Button { Text = "Log out", Toggle = logged } + end, + + [false] = function() + return Button { Text = "Log in", Toggle = logged } + end + } + ``` + +## indexes() + +Maps each index in a table source to an object. + +- **Type** + + ```lua + function indexes( + source: () -> Map, + transform: (value: () -> VI, index: KI) -> VO + ): Array + +- **Details** + + The transform function is called only ever *once* for each index in the + source table. The first argument is a source containing the index's value + and the second argument is just the index. + + Anytime a new index is added, the transform function will be called again + for that new index. + + Anytime an existing index value changes, the transform function is not rerun, + instead the source value for that index will update, causing anything + depending on it to update too. + + Returns a state containing an array of all objects returned by the + transform. + + ::: warning + `transform()` cannot yield. + ::: + +- **Example** + + The intended purpose of this function is to map each index in a table to + a UI element. + + ```lua + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = indexes(items, function(item, i) + return ItemDisplay { + Name = function() + return item().name + end, + + Image = function() + return "rbxassetid://" .. item().icon + end, + + LayoutOrder = i + } + end) + ``` + +## values() + +Maps each value in a table source to an object. + +- **Type** + + ```lua + function values( + source: () -> Map, + transform: (value: VI, index: () -> KI) -> VO + ): Array + +- **Details** + + The transform function is called only ever *once* for each value in the + source table. The first argument is the index's value and + the second argument is a source containing the index. + + Anytime a new value is added, the transform function will be called again + for that new value. + + Anytime an existing value's index changes, the transform function is not + rerun, instead the source index for that value will update, causing anything + depending on it to update too. + + Returns a state containing an array of all objects returned by the + transform. + + ::: warning + `transform()` cannot yield. + ::: + + ::: warning + Having primitive values in the source table can cause unexpected behavior, + as duplicate primitives can result in multiple index sources being bound + to the same UI element. + ::: + +- **Example** + + The intended purpose of this function is to map each value in a table to + a UI element. + + ```lua + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = values(items, function(item, i) + return ItemDisplay { + Name = item.Name + + Image = "rbxassetid://" .. item.icon, + + LayoutOrder = i + } + end) + ``` + +- **Extra** + + When should you use `indexes()` and `values()`? + + `values()` should be used when you have a fixed set of objects where the + same objects can be re-arranged in the source table. It maps a value to a + UI element. + + e.g. + - List of all players. + - Inventory of items. + - Chat message history. + - Toast notifications. + + `indexes()` should be used in other cases, especially when your source table + has primitive value. It maps an index to a UI element. + + e.g. + - List of character or weapon stats. + + In most cases, both functions will appear to have the same behavior. + The main difference is performance, picking the right function to use can + result in less property updates and less re-renders. + +-------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 35559ee..22adba5 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -69,3 +69,5 @@ Runs a given function where any sources read will not track its reactive scope. a(1) print(sum()) -- 2 ``` + +-------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 54c30fc..0472954 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -12,14 +12,14 @@ and identifying improper usage. Currently, strict mode will: 1. Run derived sources twice a source updates. -2. Run effecters twice when a source updates. +2. Run effects twice when a source updates. 3. Throw an error if yields occur where they are not allowed. 4. Checks for `indexes()` and `values()` returning primitive values. 5. Checks for duplicate nested properties at same depth. 6. Better error reporting and stack traces. 7. Checks for multiple `cleanup()` calls in the same function scope. -By rerunning sources and effecters, any side-effects are made more apparent. +By rerunning sources and effects, any side-effects are made more apparent. This also helps ensure that cleanups are being handled correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, diff --git a/docs/tut/control-flow/indexes.md b/docs/tut/control-flow/indexes.md new file mode 100644 index 0000000..70864e7 --- /dev/null +++ b/docs/tut/control-flow/indexes.md @@ -0,0 +1,66 @@ +# Control Flow + +Vide has specific functions for dealing with sources that store a table value. + +Often, you will have a table of values that will be displayed in a similar +manner. Rather than manually looping over each value to generate a corresponding +UI element, Vide provides functions `indexes()` and `values()` to do this for +you. + +`indexes()` maps each *index* in a table to a UI element. + +```lua +local names = source { "a", "b", "c" } + +local elements = indexes(names, function(name, i) + return create "TextLabel" { + Text = function() + return "Name: " .. name() + end, + + LayoutOrder = i + } +end) +``` + +What happens here is the given callback is only ever ran *once* for each index +in the table. The callback receives two arguments, a *source* containing the +index's value and then the index itself. + +Anytime the value at a corresponding index changes, the source for that index +value is updated, causing the UI element depending on it to update too. + +`values()` behaves similarly, except it maps each *value* in a table to a UI +element. + +```lua +type Item = { + Name: string, + Icon: number +} + +local items = source({} :: Array) + +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 new file mode 100644 index 0000000..70864e7 --- /dev/null +++ b/docs/tut/control-flow/switch.md @@ -0,0 +1,66 @@ +# Control Flow + +Vide has specific functions for dealing with sources that store a table value. + +Often, you will have a table of values that will be displayed in a similar +manner. Rather than manually looping over each value to generate a corresponding +UI element, Vide provides functions `indexes()` and `values()` to do this for +you. + +`indexes()` maps each *index* in a table to a UI element. + +```lua +local names = source { "a", "b", "c" } + +local elements = indexes(names, function(name, i) + return create "TextLabel" { + Text = function() + return "Name: " .. name() + end, + + LayoutOrder = i + } +end) +``` + +What happens here is the given callback is only ever ran *once* for each index +in the table. The callback receives two arguments, a *source* containing the +index's value and then the index itself. + +Anytime the value at a corresponding index changes, the source for that index +value is updated, causing the UI element depending on it to update too. + +`values()` behaves similarly, except it maps each *value* in a table to a UI +element. + +```lua +type Item = { + Name: string, + Icon: number +} + +local items = source({} :: Array) + +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 new file mode 100644 index 0000000..70864e7 --- /dev/null +++ b/docs/tut/control-flow/values.md @@ -0,0 +1,66 @@ +# Control Flow + +Vide has specific functions for dealing with sources that store a table value. + +Often, you will have a table of values that will be displayed in a similar +manner. Rather than manually looping over each value to generate a corresponding +UI element, Vide provides functions `indexes()` and `values()` to do this for +you. + +`indexes()` maps each *index* in a table to a UI element. + +```lua +local names = source { "a", "b", "c" } + +local elements = indexes(names, function(name, i) + return create "TextLabel" { + Text = function() + return "Name: " .. name() + end, + + LayoutOrder = i + } +end) +``` + +What happens here is the given callback is only ever ran *once* for each index +in the table. The callback receives two arguments, a *source* containing the +index's value and then the index itself. + +Anytime the value at a corresponding index changes, the source for that index +value is updated, causing the UI element depending on it to update too. + +`values()` behaves similarly, except it maps each *value* in a table to a UI +element. + +```lua +type Item = { + Name: string, + Icon: number +} + +local items = source({} :: Array) + +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 aa334f8..7b00dac 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -19,11 +19,11 @@ Some of the main focuses behind Vide's design choices: - Reducing the amount of imports needed for usage by leveraging Luau's syntax and semantics. - Being completely typecheckable. -- Flexibility, particularly with integrating other libraries and allowing users - to use their own patterns. +- Flexibility with integrating other libraries and allowing users to use their + own patterns. - Independence from instance lifetimes. -- A powerful reactive system that can surgically update properties as a result - of state changes. +- A powerful reactive system that can update specific properties as a result of + state changes, updates are immediate with no diffing needed. ## Structure Of A Vide App diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 0dc77bf..67c1f58 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -40,3 +40,6 @@ the updating of the Text property anytime the count is changed. All observable changes to the user are considered to be side-effects of the reactive system. + +You should not update other sources using an effect. Improper usage can lead to +unecessary updates and infinite loops. diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/8-control-flow.md index 70864e7..f590145 100644 --- a/docs/tut/crash-course/8-control-flow.md +++ b/docs/tut/crash-course/8-control-flow.md @@ -1,66 +1,95 @@ # Control Flow -Vide has specific functions for dealing with sources that store a table value. +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, Vide provides functions `indexes()` and `values()` to do this for -you. - -`indexes()` maps each *index* in a table to a UI element. +UI element, `indexes()` can autmatically run a transform function for each +index and value, generating a UI element. ```lua -local names = source { "a", "b", "c" } +local todoList = { + "Finish the crash course", + "Star vide's GitHub" +} -local elements = indexes(names, function(name, i) +local elements = indexes(todoList, function(todo, i) return create "TextLabel" { Text = function() - return "Name: " .. name() + return i .. ": " .. todo() 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 +mount(function() + return create "ScreenGui" { + create "UIListLayout" {}, elements } -end) +end, game.StarterGui) ``` -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. +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. -Any time a value in a table changes index, the source for that value is updated, -causing the UI element position to change. +When the value at an index is changed, the function is not reran. Instead, the +given source is updated instead. -In certain cases `values()` can cause less recalculation and rerenders than -`indexes()` like when items are re-arranged and shifted within a table. +`indexes()` is said to map each *index* in a table to a UI element, each index +has a single corresponding element. -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. +An element is only destroyed if the value of an index is set to `nil`. diff --git a/docs/tut/reactive-scoping.md b/docs/tut/reactive-scoping.md new file mode 100644 index 0000000..5b983ab --- /dev/null +++ b/docs/tut/reactive-scoping.md @@ -0,0 +1,61 @@ +# Reactive Scoping + +This is a brief document designed to give the user more insight into how Vide's +reactive graph works. + +Each time you create and derive sources, a new node representing that source is +created and added to the reactive graph. Each node stores a value and a +side-effect function. Each node also keeps track of its parents and children, +as well as any cleanups registered. + +Any time a node is updated, Vide will traverse and update that node's children, +its children's children, etc, until all nodes descending from that node has been +updated. Traversal will stop at a node if that node's cached value does not +change after an update. + +For every node that is updated, a scope is opened for that node. These scopes +are referred to as "reactive scopes". Any source read from within a node's scope +will that node as a child. This is similar to cleanups, anytime a cleanup is +registered, it is added to the node of the currently active scope. + +The way Vide tracks reactive scopes, is by using a stack of nodes. The current +active reactive scope is the node at the top of this stack. + +When destroying a node, its descendents are traversed and also destroyed. +When being destroyed, a node's connections (parents and children) are cleared, +and any pending cleanup functions are ran. + +The purpose of `root()` (which is called internally by `mount()`) is to setup +the root node which will track any node created or derived inside its scope, or +any cleanups registered. Without it, nodes could be garbage collected without a +chance to run pending cleanups which can cause memory leakage. + +Control flow functions in Vide are special, as they can dynamically create and +destroy new root scopes. + +It is the combination of the above which allows us to write components like so: + +```lua +local function Counter() + local count = source(0) + + local connection = stepped:Connect(function() count(count() + 1) end) + + cleanup(function() connection:Disconnect() end) + effect(function() print(count()) end) + + return create "TextLabel" { Text = count } +end +``` + +Vide doesn't recognise this as a "component", that is a user abstraction. Vide +just sees this as a function that creates nodes in the reactive graph. + +Whenever the reactive scope that calls this function is destroyed, like by a +control flow function, the registered cleanup will be called, and the effect +(which is just a node on the reactive graph) is destroyed. The returned instance +and the bound `count` source is just considered to be a side-effect, and with +the reactive scope from which the side-effects stem from destroyed, the instance +can be garbage collected - everything is nicely cleaned up. + +> todo: add graphics diff --git a/src/action.luau b/src/action.luau index 3efcfe6..f40bf3a 100644 --- a/src/action.luau +++ b/src/action.luau @@ -3,7 +3,7 @@ type Action = { callback: (Instance) -> () } -local ActionMT = {} +local ActionMT = table.freeze {} local function is_action(v: any) return getmetatable(v) == ActionMT diff --git a/src/bind.luau b/src/bind.luau index 99718a5..bcd0701 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,6 +1,7 @@ if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) +local trace = require(script.Parent.trace) local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node @@ -9,34 +10,11 @@ local get_scope = graph.get_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner --- todo: replace with throw's method -local root do - local src = debug.info(1, "s") - root = string.sub(src, 1, #src - 5) -end - -local function traceback(skips: number) -- ensures trace begins outside of any vide library file - local s = 1 - - repeat - s += 1 - local path = debug.info(s, "s") - - local found = not string.find(path, root) - - if found then - skips -= 1 - end - until found and skips < 0 - - return debug.traceback(nil, s) -end - function create_binding(updater: (T) -> T, binding: T) if flags.strict then -- track bind creation trace local fn = updater - local bind_trace = traceback(0) + local bind_trace = debug.traceback(nil, trace()-1) updater = function(...) local ok, result = xpcall(fn, function(err: string) return err diff --git a/src/cleanup.luau b/src/cleanup.luau index 2d8fc73..05a516f 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -5,12 +5,13 @@ local graph = require(script.Parent.graph) local get_scope = graph.get_scope local add_cleanup = graph.add_cleanup -local function cleanup(fn: () -> ()) +local function cleanup(callback: () -> ()) local scope = get_scope() - if not scope then throw("cannot cleanup in a non-reactive scope") end - assert(scope) + if not scope then + throw("cannot cleanup in a non-reactive scope") + end; assert(scope) - add_cleanup(scope, fn) + add_cleanup(scope, callback) end return cleanup diff --git a/src/create.luau b/src/create.luau index 736ed26..0ce889e 100644 --- a/src/create.luau +++ b/src/create.luau @@ -7,11 +7,11 @@ local defaults = require(script.Parent.defaults) local apply = require(script.Parent.apply) local memoize = require(script.Parent.memoize) -local function create_instance(class_name: string) - local ok, instance: Instance = pcall(Instance.new, class_name :: any) - if not ok then throw(`invalid class name, could not create instance of class { class_name }`) end +local function create_instance(class: string) + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then throw(`invalid class name, could not create instance of class { class }`) end - local default: { [string]: unknown }? = defaults[class_name] + local default: { [string]: unknown }? = defaults[class] if default then for i, v in next, default do (instance :: any)[i] = v diff --git a/src/defaults.luau b/src/defaults.luau index 28ecf28..03badd6 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,10 +1,11 @@ local Enum = game and Enum or require "test/mock".Enum :: never local Color3 = game and Color3 or require "test/mock".Color3 :: never +local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never return { Part = { Material = Enum.Material.SmoothPlastic, - --Size = Vector3.new(1, 1, 1), + Size = Vector3.new(1, 1, 1), Anchored = true }, diff --git a/src/derive.luau b/src/derive.luau index 7c2a763..29b915a 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -8,13 +8,13 @@ local track = graph.track local get_scope = graph.get_scope local evaluate_node = graph.evaluate_node -local function derive(fn: () -> T): () -> T +local function derive(source: () -> T): () -> T local owner = get_scope() if not owner then throw("cannot derive in non-reactive scope") end; assert(owner) - local node = create_node(false :: any, fn) + local node = create_node(false :: any, source) set_owner(node, owner) evaluate_node(node) diff --git a/src/effect.luau b/src/effect.luau index 8c54d23..bc53660 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -7,16 +7,16 @@ local get_scope = graph.get_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner -local function effect(effect: (T) -> T, initial_value: T) +local function effect(callback: (T) -> T, initial_value: T) local owner = get_scope() if not owner then throw("cannot create effect in non-reactive scope") end; assert(owner) - local node = create_node(initial_value, effect) + local node = create_node(initial_value, callback) set_owner(node, owner) evaluate_node(node) end -return effect :: ((effect: (T) -> T, initial_value: T) -> ()) & ((effect: () -> ()) -> ()) +return effect :: ((callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ()) diff --git a/src/graph.luau b/src/graph.luau index b355e6f..a70fe57 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -16,10 +16,11 @@ export type Node = { [number]: Node } +-- 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...) -> (boolean, string?), T...) -> () do +local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do local t = { __mode = "kv" } setmetatable(t, t) @@ -40,10 +41,6 @@ local check_for_yield: (fn: (T...) -> (boolean, string?), T...) -> () do end end -local function get_stack_scope(offset: number): Node? - return scopes[scopes.n - offset] -end - local function get_scope(): Node? return scopes[scopes.n] end @@ -120,8 +117,18 @@ end local function evaluate_node(node: Node) local cur_value = node.cache - run_cleanups(node) -- todo: move in scope? + if flags.strict then + run_cleanups(node) + open_scope(node) + local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) + + close_scope() + + if not ok then throw(err :: string) end + end + + run_cleanups(node) -- todo: move in scope? open_scope(node) local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) @@ -150,6 +157,7 @@ local function update(node: StartNode) table.clear(update_queue) end + -- unparent all children and queue for eval do local child = node[1] while child do -- todo: case where child in owner context @@ -162,6 +170,7 @@ local function update(node: StartNode) end end + -- evaluate all queued children for i = n0 + 1, n do local child = update_queue[i] if not child.effect then continue end @@ -178,36 +187,33 @@ end local function track(node: StartNode) local scope = get_scope() - if scope and scope.effect then -- todo + if scope and scope.effect then -- do not track nodes with no effect add_child(node, scope) end end local function create_node(value: T, effect: false | (T) -> T): Node - local node: Node = { + return { cache = value, effect = effect, - cleanups = false :: false, + cleanups = false, parents = {}, } - - return node -end - -local function get_children(node: Node): { Node } - return { unpack(node) } :: { Node } end local function create_start_node(value: T): StartNode return { cache = value } end +local function get_children(node: Node): { Node } + return { unpack(node) } :: { Node } +end + return table.freeze { open_scope = open_scope, close_scope = close_scope, evaluate_node = evaluate_node, get_scope = get_scope, - get_stack_scope = get_stack_scope, add_cleanup = add_cleanup, set_owner = set_owner, destroy = destroy, @@ -217,5 +223,6 @@ return table.freeze { add_child = add_child, create_node = create_node, create_start_node = create_start_node, - get_children = get_children + get_children = get_children, + scopes = scopes } diff --git a/src/maps.luau b/src/maps.luau index 3f2b821..da5c5d7 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -1,7 +1,5 @@ if not game then script = require "test/relative-string" end --- todo: more testing needed regarding `cleanup()` usage - local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) @@ -71,7 +69,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, local cv = input_cache[i] if cv ~= v then - if cv == nil then + if cv == nil then -- create new scope and run transform local scope = create_node(false, false) scopes[i] = scope :: Node @@ -87,20 +85,22 @@ local function indexes(input: () -> Map, transform: (() -> VI, close_scope() - if not ok then error(result, 0) end + if not ok then + close_scope() -- subowner scope + error(result, 0) + end input_nodes[i] = node - input_cache[i] = v output_cache[i] = result - else + else -- update source input_nodes[i].cache = v update(input_nodes[i]) - input_cache[i] = v end + + input_cache[i] = v end end - -- todo: handle early error close_scope() local output_array = table.create(#scopes) @@ -118,7 +118,6 @@ local function indexes(input: () -> Map, transform: (() -> VI, evaluate_node(node) - return function() track(node) return node.cache @@ -161,7 +160,7 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] - if cv == nil then + if cv == nil then -- create new scope and run transform local scope = create_node(false, false) scopes[v] = scope :: Node @@ -177,20 +176,23 @@ local function values(input: () -> Map, transform: (VI, () -> close_scope() - if not ok then error(result, 0) end + if not ok then + close_scope() -- subowner scope + error(result, 0) + end input_nodes[v] = node output_cache[v] = result - else + else -- update source if cv ~= i then input_nodes[v].cache = i update(input_nodes[v]) end + cur_input_cache[v] = nil end end - -- todo: handle early error close_scope() -- remove old values diff --git a/src/root.luau b/src/root.luau index 1ed5db8..766f1fb 100644 --- a/src/root.luau +++ b/src/root.luau @@ -31,4 +31,4 @@ local function root(fn: () -> T): (T, () -> ()) end end -return root +return root :: ((fn: () -> T) -> (T, () -> ())) & ((fn: () -> ()) -> (nil, () -> ())) diff --git a/src/source.luau b/src/source.luau index 2e960a2..f1307fc 100644 --- a/src/source.luau +++ b/src/source.luau @@ -9,8 +9,6 @@ local update = graph.update export type Source = (() -> T) & ((T) -> T) local function source(initial_value: T): Source - --assert(get_scope()) - local node = create_start_node(initial_value) return function(...): T diff --git a/src/spring.luau b/src/spring.luau index ee76e10..9503e15 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -28,8 +28,7 @@ type StartNode = graph.StartNode local create_node = graph.create_node local create_start_node = graph.create_start_node local get_scope = graph.get_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local evaluate_node = graph.evaluate_node local update = graph.update local set_owner = graph.set_owner local track = graph.track @@ -43,6 +42,8 @@ local function Vec3(x: number?, y: number?, z: number?) return Vector3.new(x, y, z) end +local ZERO = Vec3(0, 0, 0) + type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 type SpringData = { @@ -67,21 +68,20 @@ type Vec6ToType = (Vec3, Vec3) -> T local type_to_vec6 = { number = function(v) - return Vec3(v, 0, 0), Vec3() + return Vec3(v, 0, 0), ZERO end :: TypeToVec6, CFrame = function(v) - -- todo: proper rotation tween return v.Position, Vec3(v:ToEulerAnglesXYZ()) end :: TypeToVec6, Color3 = function(v) - -- todo: hsv - return Vec3(v.R, v.G, v.B), Vec3() + -- todo: hsv, oklab? + return Vec3(v.R, v.G, v.B), ZERO end :: TypeToVec6, UDim = function(v) - return Vec3(v.Scale, v.Offset, 0), Vec3() + return Vec3(v.Scale, v.Offset, 0), ZERO end :: TypeToVec6, UDim2 = function(v) @@ -89,11 +89,11 @@ local type_to_vec6 = { end :: TypeToVec6, Vector2 = function(v) - return Vec3(v.X, v.Y, 0), Vec3() + return Vec3(v.X, v.Y, 0), ZERO end :: TypeToVec6, Vector3 = function(v) - return v, Vec3() + return v, ZERO end :: TypeToVec6, Rect = function(v) @@ -151,25 +151,11 @@ setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T local owner = get_scope() - if not owner then throw("cannot derive in non-reactive scope") end - assert(owner) - - local updater = create_node(false) - updater.effect = true :: any -- todo - - set_owner(updater, owner) - open_scope(updater) - - local initial_value = source() - - close_scope() - - local vtype = typeof(initial_value) - - local x1_123, x1_456 = type_to_vec6[vtype](initial_value) + if not owner then + throw("cannot derive in non-reactive scope") + end; assert(owner) -- https://en.wikipedia.org/wiki/Damping - -- todo: calculate damped freq at 10tau instead of natural freq local w_n = 2*math.pi / (period or 1) local z = damping_ratio or 1 @@ -182,27 +168,38 @@ local function spring(source: () -> T, period: number?, damping_ratio: number k = k, c = c, - x0_123 = x1_123, - x1_123 = x1_123, - v_123 = Vec3(), + x0_123 = ZERO, + x1_123 = ZERO, + v_123 = ZERO, - x0_456 = x1_456, - x1_456 = x1_456, - v_456 = Vec3(), + x0_456 = ZERO, + x1_456 = ZERO, + v_456 = ZERO, - source_value = initial_value, + source_value = false :: any, } - local output = create_start_node(initial_value) + local output = create_start_node(false :: any) - updater.effect = function() - local v = source() - data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v) - data.source_value = v + local function updater_effect() + local value = source() + data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) + data.source_value = value springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs - return false + return value end + local updater = create_node(false :: any, updater_effect) + + set_owner(updater, owner) + evaluate_node(updater) + + -- set initial position to goal + data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + + -- set output to goal + output.cache = data.source_value + return function() track(output) return output.cache diff --git a/src/switch.luau b/src/switch.luau index 97a5f72..31c2b4a 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -22,22 +22,27 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () throw("cannot switch in non-reactive scope") end; assert(owner) - local scope: Node? + local last_scope: Node? local last_component: (() -> U)? - local function update(): U? + local function update(cached): U? local component = map[source()] - if component == last_component then return nil end + if component == last_component then return cached end last_component = component - if scope then - destroy(scope :: Node) + if last_scope then + destroy(last_scope :: Node) + last_scope = nil end if component == nil then return nil end + if type(component) ~= "function" then + throw("map must map a value to a function") + end + local new_scope = create_node(false, false) - scope = new_scope :: Node + last_scope = new_scope :: Node set_owner(new_scope, owner) open_scope(new_scope) @@ -51,7 +56,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () return result end - local node = create_node(nil, update :: () -> any) + local node = create_node(nil :: any, update) set_owner(node, owner) evaluate_node(node) diff --git a/src/throw.luau b/src/throw.luau index 712cdc2..d3ea687 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -1,32 +1,9 @@ --- returns path to file as an array with each directory --- accounts for Roblox and Luau contexts -local function get_path(s) - if string.sub(s, #s - 4, #s) == ".luau" then - s = string.sub(s, 1, #s - 5) - end +if not game then script = require "test/relative-string" end - return string.split(s, string.match(s, "%w+/") and "/" or ".") -end +local trace = require(script.Parent.trace) --- get directory of vide root -local root do - local path = get_path(debug.info(1, "s")) - root = path[#path - 1] -end - --- throws an error, ensuring stack trace begins at the first callsite outside --- of all vide library files -local function throw(msg: string) - local stack = 1 - - local path = get_path(debug.info(stack, "s")) - - while path[#path] == root or path[#path - 1] == root do - stack += 1 - path = get_path(debug.info(stack, "s")) - end - - error(msg, stack) +local function throw(msg): any + error(msg, trace()-1) end return throw diff --git a/src/trace.luau b/src/trace.luau new file mode 100644 index 0000000..04672ff --- /dev/null +++ b/src/trace.luau @@ -0,0 +1,29 @@ +-- returns path to file as an array with each directory +-- accounts for Roblox and Luau contexts +local function get_path(s) + if string.sub(s, #s - 4, #s) == ".luau" then + s = string.sub(s, 1, #s - 5) + end + + return string.split(s, string.match(s, "%w+/") and "/" or ".") +end + +-- get directory of vide root +local root do + local path = get_path(debug.info(1, "s")) + root = path[#path - 1] +end + +-- finds the first stack depth outside of any vide library function +return function(): number + local stack = 1 + + local path = get_path(debug.info(stack, "s")) + + while path[#path] == root or path[#path - 1] == root do + stack += 1 + path = get_path(debug.info(stack, "s")) + end + + return stack +end diff --git a/src/untrack.luau b/src/untrack.luau index abdfb78..230674d 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -7,17 +7,21 @@ 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 not scope then + throw("cannot untrack in non-reactive scope") + end; assert(scope) + -- sources are only tracked if the node in scope has an effect local effect = scope.effect scope.effect = false - local v = source() + local ok, result = pcall(source) scope.effect = effect :: () -> () - return v + if not ok then error(result, 0) end + + return result end return untrack diff --git a/test/benchmark.luau b/test/benchmark.luau index 47f766e..bf7085a 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -1,14 +1,19 @@ local BENCH, START = require("test/testkit").benchmark() local vide = require "src/init" +local root = vide.root +local source = vide.source +local derive = vide.derive +local indexes = vide.indexes +local values = vide.values +local cleanup = vide.cleanup +local create = vide.create local N = 2^18 -- 262144 -- todo: wide and deep graph benchmarks BENCH("create source", function() - local source = vide.source - local cache = table.create(N) for i = 1, START(N) do @@ -17,7 +22,7 @@ BENCH("create source", function() end) BENCH("get value", function() - local src = vide.source(1) + local src = source(1) for i = 1, START(N) do src() @@ -25,7 +30,7 @@ BENCH("get value", function() end) BENCH("set value", function() - local src = vide.source(1) + local src = source(1) for i = 1, START(N) do src(i) @@ -33,12 +38,10 @@ BENCH("set value", function() end) BENCH("derive 1 source", function() - local derive = vide.derive - local cache = table.create(N) - local src = vide.source(1) + local src = source(1) - vide.root(function() + root(function() for i = 1, START(N) do cache[i] = derive(function() return src() @@ -49,8 +52,6 @@ BENCH("derive 1 source", function() end) BENCH("derive 4 sources", function() - local derive = vide.derive - local cache = table.create(N) local src = vide.source(1) local src2 = vide.source(2) @@ -68,11 +69,11 @@ BENCH("derive 4 sources", function() end) end) -BENCH("set derived value", function() - local src = vide.source(1) +BENCH("update 1->1 graph", function() + local src = source(1) - vide.root(function() - local _derived = vide.derive(function() return src() end) + root(function() + local _derived = derive(function() return src() end) for i = 1, START(N) do src(i) @@ -82,9 +83,104 @@ BENCH("set derived value", function() end) end) +BENCH("update 1->1 graph with cleanup", function() + local src = source(1) + + root(function() + derive(function() + cleanup(function() end) + return src() + end) + + for i = 1, START(N) do + src(i) + end + + return nil + end) +end) + +BENCH("update 1->1000 graph", function() + local src = source(-1) + + root(function() + for i = 1, 1000 do + derive(function() return src() end) + end + + src(0) + + for i = 1, START(10) do + src(i) + end + end) +end) + +BENCH("update 1->1->1->1...1000 graph", function() + local src = source(-1) + + root(function() + local last = src + for i = 1, 1000 do + local l = last + last = derive(function() return l() end) + end + + src(0) + + for i = 1, START(10) do + src(i) + end + end) +end) + +-- todo: repeat with batching +BENCH("update 1000->1 graph", function() + local srcs = {} + for i = 1, 1000 do + srcs[i] = source(0) + end + + root(function() + derive(function() + for i = 1, 1000 do + srcs[i]() + end + return false + end) + + for i = 1, START(1) do + for idx = 1, 1000 do + srcs[idx](i) + end + end + end) +end) + +-- todo: optimize, repeat with batching +BENCH("update 1000x 1->1 common extern. graph", function() + local ext = source(-1) + + root(function() + local srcs = {} + for i = 1, 1000 do + srcs[i] = source(0) + derive(function() return srcs[i]() + ext() end) + end + + ext(0) + + for i = 1, START(10) do + for idx = 1, 1000 do + srcs[idx](i) + end + end + end) +end) + BENCH("apply 0 properties", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} + local instance = create("Frame") {} for i = 1, START(N) do apply(instance, {}) @@ -93,7 +189,7 @@ end) BENCH("apply 8 properties", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} + local instance = create("Frame") {} for i = 1, START(N) do apply(instance, { @@ -112,10 +208,10 @@ end) BENCH("bind property", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} - local src = vide.source(1) + local instance = create("Frame") {} + local src = source(1) - vide.root(function() + root(function() for i = 1, START(N) do apply(instance, { Text = src @@ -129,10 +225,10 @@ end) BENCH("update binding", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} - local src = vide.source(1) + local instance = create("Frame") {} + local src = source(1) - vide.root(function() + root(function() apply(instance, { Text = src }) @@ -154,12 +250,12 @@ BENCH("indexes() all new", function() data[i] = i end - local src = vide.source(data) + local src = source(data) - vide.root(function() + root(function() START(N) - local _list = vide.indexes(src, function(v, i) + local _list = indexes(src, function(v, i) return {} end) @@ -174,10 +270,10 @@ BENCH("indexes() no change", function() data[i] = i end - local src = vide.source(data) + local src = source(data) - vide.root(function() - local _list = vide.indexes(src, function(v, i) + root(function() + local _list = indexes(src, function(v, i) return {} end) @@ -196,11 +292,11 @@ BENCH("indexes() all change", function() data[i] = i end - local src = vide.source(data) + local src = source(data) - vide.root(function() - local _list = vide.indexes(src, function(v, i) + root(function() + local _list = indexes(src, function(v, i) return {} end) @@ -225,10 +321,10 @@ BENCH("indexes() all remove", function() data[i] = i end - local src = vide.source(data) + local src = source(data) - vide.root(function() - local _list = vide.indexes(src, function(v, i) + root(function() + local _list = indexes(src, function(v, i) return {} end) @@ -249,12 +345,12 @@ BENCH("values() all new", function() data[i] = {} end - local src = vide.source(data) + local src = source(data) - vide.root(function() + root(function() START(N) - local _list = vide.values(src, function(v, i) + local _list = values(src, function(v, i) return {} end) @@ -269,10 +365,10 @@ BENCH("values() no change", function() data[i] = {} end - local src = vide.source(data) + local src = source(data) - vide.root(function() - local _list = vide.values(src, function(v, i) + root(function() + local _list = values(src, function(v, i) return {} end) @@ -293,10 +389,10 @@ BENCH("values() all change", function() data[i] = {} end - local src = vide.source(data) + local src = source(data) - vide.root(function() - local _list = vide.values(src, function(v, i) + root(function() + local _list = values(src, function(v, i) return {} end) @@ -322,10 +418,10 @@ BENCH("values() all remove", function() data[i] = {} end - local src = vide.source(data) + local src = source(data) - vide.root(function() - local _list = vide.values(src, function(v, i) + root(function() + local _list = values(src, function(v, i) return {} end) @@ -342,9 +438,9 @@ end) N *= 1024 BENCH("register new cleanup", function() - local cleanup = vide.cleanup + local cleanup = cleanup - vide.root(function() + root(function() local cleaner = function() end local callers = {} @@ -368,7 +464,6 @@ do -- the purpose of the two following benchmarks is to measure the overhead of -- aggregate construction BENCH("set explicit mock vector2", function() - local create = vide.create local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -384,7 +479,6 @@ do end) BENCH("set aggregate mock vector2", function() - local create = vide.create local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -400,4 +494,47 @@ do end) end +-- innacurate due to no Vector3 in vanilla Luau +-- mock vector is 200x slower than native vector + +-- BENCH("spring update", function() +-- local root, source, spring = vide.root, vide.source, vide.spring + +-- local src = source(0) + +-- root(function() +-- for i = 1, N do +-- spring(src) +-- end + +-- START(N) + +-- src(1) + +-- return nil +-- end) +-- end) + +-- N /= 1024 + +-- BENCH("spring step", function() +-- local root, source, spring = vide.root, vide.source, vide.spring + +-- local src = source(0) + +-- root(function() +-- for i = 1, N do +-- spring(src) +-- end + +-- src(1) + +-- START(N) + +-- vide.step(1/60) + +-- return nil +-- end) +-- end) + return nil diff --git a/test/relative-string.luau b/test/relative-string.luau index 824036c..232215c 100644 --- a/test/relative-string.luau +++ b/test/relative-string.luau @@ -1,5 +1,6 @@ local function dir(directory: string) - return setmetatable({} :: { [string]: any }, { __index = function(_, path) return directory .. path end }) + return setmetatable({} :: { [string]: any }, + { __index = function(_, path) return directory .. path end }) end local script = dir "src/" diff --git a/test/spring-test.luau b/test/spring-test.luau index 21f2d18..c22d955 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -65,7 +65,7 @@ local function main() until false end -main() +vide.root(main) diff --git a/test/tests.luau b/test/tests.luau index b374479..030f846 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -33,7 +33,6 @@ local NIL = nil :: any TEST("graph", function() local create_node = graph.create_node - local create_start_node = graph.create_start_node local track = graph.track local update = graph.update local add_child = graph.add_child @@ -45,13 +44,11 @@ TEST("graph", function() local destroy = graph.destroy local function node(v: T?) - local n = create_node(v or false) - n.effect = function() end - return n + return create_node(v or false, function(x) return not x end) end local function scope() - return create_node(false) + return create_node(false, false) end local function cleanup(fn: () -> ()) @@ -82,17 +79,18 @@ TEST("graph", function() local count = 0 - local function effect() + local function effect(x) track(a) track(b) count += 1 + return not x end c.effect = effect open_scope(c) - effect() + effect(c.cache) close_scope() @@ -107,9 +105,9 @@ TEST("graph", function() local a, b, c, d = node(), node(), node(), node() local b_cnt, c_cnt, d_cnt = 0, 0, 0 - function b.effect() b_cnt += 1 end - function c.effect() c_cnt += 1 end - function d.effect() d_cnt += 1 end + function b.effect(x) b_cnt += 1; return not x end + function c.effect(x) c_cnt += 1; return not x end + function d.effect(x) d_cnt += 1; return not x end open_scope(b); track(a); close_scope() open_scope(c); track(a); close_scope() @@ -125,9 +123,10 @@ TEST("graph", function() do CASE "duplicate child on rerun" local a, b, c = node(), node(), node() - function c.effect() + function c.effect(x) track(a) track(b) + return not x end open_scope(c); assert(c.effect)(NIL); close_scope() @@ -165,7 +164,6 @@ TEST("graph", function() do open_scope(root) clean "root" items_updated = node() - items_updated.effect = function() end track(items_updated) -- should not add_child(root, items_updated) @@ -177,7 +175,6 @@ TEST("graph", function() do open_scope(scope1) clean "scope1" bind1 = node() - bind1.effect = function() end add_child(scope1, bind1) do open_scope(bind1) @@ -190,7 +187,6 @@ TEST("graph", function() do open_scope(scope2) clean "scope2" bind2 = node() - bind2.effect = function() end add_child(scope2, bind2) do open_scope(bind2) clean "bind2" @@ -252,13 +248,47 @@ TEST("graph", function() end do CASE "nodes garbage collection" - local wref = weak { create_node(1) } + local wref = weak { node(1) } destroy(wref[1]) gc() CHECK(not wref[1]) end end) +TEST("mount()", function() + local mount = vide.mount + local create = vide.create + local source = vide.source + local cleanup = vide.cleanup + + local screen = create "ScreenGui" {} + + local text = source "foo" + local count = 0 + + local unmount = mount(function() + cleanup(function() + count += 1 + end) + + return create "TextLabel" { + Name = "TextLabel", + Text = text + } + end, screen) + + local label = screen:FindFirstChild "TextLabel" :: TextLabel + CHECK(label) + CHECK(label.Text == "foo") + + text "bar" + CHECK(label.Text == "bar") + CHECK(count == 0) + + unmount() + CHECK(count == 1) +end) + TEST("source()", wrap_root(function() local source = vide.source local effect = vide.effect @@ -422,7 +452,7 @@ TEST("derive()", wrap_root(function() local _, destroy = root(function() - local b = derive(function() + local _b = derive(function() cleanup(function() count += 1 end) @@ -534,13 +564,13 @@ TEST("cleanup()", wrap_root(function() end do CASE "cleanup on rerun" - local state = source(1) + local src = source(1) local effected = 0 local cleaned = 0 effect(function() - state() + src() effected += 1 cleanup(function() cleaned += 1 @@ -550,27 +580,27 @@ TEST("cleanup()", wrap_root(function() CHECK(effected == 1) CHECK(cleaned == 0) - state(2) + src(2) CHECK(effected == 2) CHECK(cleaned == 1) end do CASE "multiple cleanup" - local state = source(1) + local src = source(1) local queue = {} effect(function() - state() + src() cleanup(function() table.insert(queue, 1) end) cleanup(function() table.insert(queue, 2) end) end) CHECK(testkit.seq(queue, {})) - state(2) + src(2) CHECK(testkit.seq(queue, { 1, 2 })) - state(3) + src(3) CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) end end)) @@ -660,7 +690,6 @@ TEST("create()", wrap_root(function() CHECK(frame:FindFirstChild "C") CHECK(frame:FindFirstChild "D") CHECK(frame:FindFirstChild "E") - CHECK(frame:FindFirstChild "F") CHECK(frame:FindFirstChild "G") end @@ -706,7 +735,7 @@ TEST("create()", wrap_root(function() CHECK(count == 1) end - do CASE "bind same state to multiple instance properties" + do CASE "bind same source to multiple instance properties" local src = source "1" local text = create "TextBox" { @@ -807,11 +836,9 @@ TEST("create()", wrap_root(function() end)) TEST("switch()", wrap_root(function() - local create = vide.create local source = vide.source local switch = vide.switch local effect = vide.effect - local derive = vide.derive local cleanup = vide.cleanup do CASE "update on source change" @@ -828,15 +855,42 @@ TEST("switch()", wrap_root(function() CHECK(count == 1) CHECK(output() == 1) + input(false) CHECK(output() == 0) CHECK(count == 2) + input(false) + CHECK(output() == 0) CHECK(count == 2) - input(nil) + + input(NIL) CHECK(output() == nil) end + do CASE "same component different map" + local input = source(0) + + local function component() + return {} + end + + local output = switch(input) { + [1] = component, + [2] = component + } + + CHECK(output() == nil) + + input(1) + local instance = output() + CHECK(instance) + + input(2) + CHECK(output() == instance) + + end + do CASE "scoped switch" local input = source(true) @@ -865,11 +919,30 @@ TEST("switch()", wrap_root(function() input(true) CHECK(switch1_count == 1) CHECK(switch0_count == 1) - input(nil) + input(NIL) CHECK(switch1_count == 2) CHECK(switch0_count == 1) CHECK(owner_count == 0) end + + do CASE "reactive stack resets after error" + local scopes = require "src/graph".scopes + local input = source(1) + + local n0 = scopes.n + + local ok = pcall(function() + switch(input) { + error :: any + } + end) + + CHECK(not ok) + + local n1 = scopes.n + + CHECK(n0 == n1) + end end)) TEST("indexes()", wrap_root(function() @@ -893,10 +966,10 @@ TEST("indexes()", wrap_root(function() do CASE "cache result" local input = source { 1, 2, 3 } - local runcount = table.create(3, 0) + local count = table.create(3, 0) local output = indexes(input, function(v, i) - runcount[i] += 1 + count[i] += 1 return v end) @@ -906,9 +979,9 @@ TEST("indexes()", wrap_root(function() CHECK(output()[2]() == 2) CHECK(output()[3]() == 4) - CHECK(runcount[1] == 1) - CHECK(runcount[2] == 1) - CHECK(runcount[3] == 1) + CHECK(count[1] == 1) + CHECK(count[2] == 1) + CHECK(count[3] == 1) end do CASE "removal reflected" @@ -989,6 +1062,27 @@ TEST("indexes()", wrap_root(function() CHECK(count[2] == 0) CHECK(count[3] == 0) end + + do CASE "reactive stack resets after error" + local scopes = require "src/graph".scopes + + local input = source { 1 } + + local n0 = scopes.n + + local ok = pcall(function() + indexes(input, function() + error("") + return NIL + end) + end) + + CHECK(not ok) + + local n1 = scopes.n + + CHECK(n0 == n1) + end end)) TEST("values()", wrap_root(function() @@ -1012,10 +1106,10 @@ TEST("values()", wrap_root(function() do CASE "cache result" local input = source { 1, 2, 3 } - local runcount = table.create(3, 0) + local count = table.create(3, 0) local output = values(input, function(v, i) - runcount[v] += 1 + count[v] += 1 return i end) @@ -1025,9 +1119,9 @@ TEST("values()", wrap_root(function() CHECK(output()[2]() == 3) CHECK(output()[3]() == 2) - CHECK(runcount[1] == 1) - CHECK(runcount[2] == 1) - CHECK(runcount[3] == 1) + CHECK(count[1] == 1) + CHECK(count[2] == 1) + CHECK(count[3] == 1) end do CASE "removal reflected" @@ -1094,6 +1188,27 @@ TEST("values()", wrap_root(function() CHECK(count[2] == 0) CHECK(count[3] == 0) end + + do CASE "reactive stack resets after error" + local scopes = require "src/graph".scopes + + local input = source { 1 } + + local n0 = scopes.n + + local ok = pcall(function() + values(input, function() + error("") + return NIL + end) + end) + + CHECK(not ok) + + local n1 = scopes.n + + CHECK(n0 == n1) + end end)) TEST("spring()", wrap_root(function() @@ -1226,7 +1341,7 @@ TEST("untrack()", wrap_root(function() CHECK(a() == untrack(a)) end - do CASE "derived state" + do CASE "derived source" local a = source(0) local b = source(0) local c = source(0) @@ -1368,127 +1483,156 @@ TEST("actions", function() end end) --- TEST("strict", function() --- vide.strict = true +TEST("changed()", wrap_root(function() + local root = vide.root + local create = vide.create + local source = vide.source + local changed = vide.changed --- local create = vide.create --- local source = vide.source --- local derive = vide.derive --- local effect = vide.effect --- local indexes, values = vide.indexes, vide.values --- local cleanup = vide.cleanup + do CASE "outputs" + local output = source(nil) --- -- do CASE "error on derived callback yield" --- -- local state = source(1) + local text = create "TextLabel" { + Text = "a", + changed("Text", output) + } --- -- local ok = pcall(function() --- -- local _derived = derive(function() --- -- coroutine.yield() --- -- return state() --- -- end) --- -- end) + --CHECK(output() == "a") + text.Text = "b" + CHECK(output() == "b") + end --- -- CHECK(not ok) --- -- end + do CASE "connection disconnected" + local text, destroy = root(function() + local output = source(nil) --- -- do CASE "error on effecter callback yield" --- -- local state = source(1) + return create "TextLabel" { + Text = "a", + changed("Text", output) + } + end) --- -- local ok = pcall(function() --- -- local _derived = effect(function() --- -- coroutine.yield() --- -- state() --- -- end) --- -- end) + destroy() -- changed() should of disconnect connection --- -- CHECK(not ok) --- -- end + -- check if instance can gc + local wref = weak { text } + text = NIL + gc() + CHECK(not wref[1]) + end +end)) --- do CASE "run derived callback twice" --- local state = source(1) --- local runcount = 0 +TEST("strict", wrap_root(function() + vide.strict = true --- local _ = derive(function() --- runcount += 1 --- return state() --- end) + local create = vide.create + local source = vide.source + local derive = vide.derive + local effect = vide.effect + local indexes, values = vide.indexes, vide.values --- CHECK(runcount == 2) --- state(2) --- CHECK(runcount == 4) --- end + do CASE "error on derived callback yield" + local src = source(1) --- do CASE "run effecter callback twice" --- local state = source(1) --- local runcount = 0 + local ok = pcall(function() + local _derived = derive(function() + coroutine.yield() + return src() + end) + end) --- effect(function() --- runcount += 1 --- state() --- end) + CHECK(not ok) + end --- CHECK(runcount == 2) --- state(2) --- CHECK(runcount == 4) --- end + do CASE "error on effecter callback yield" + local src = source(1) --- do CASE "indexes() error if primitive" --- local state = source { 1 } + local ok = pcall(function() + effect(function() + coroutine.yield() + src() + end) + end) --- local ok = pcall(function() --- indexes(state, function() return 1 end) --- end) + CHECK(not ok) + end --- CHECK(not ok) --- end + do CASE "run derived callback twice" + local src = source(1) + local count = 0 --- do CASE "values() error if duplicate" --- local state = source { 1, 2, 1 } + local _ = derive(function() + count += 1 + return src() + end) --- local ok = pcall(function() --- values(state, function() return {} end) --- end) + CHECK(count == 2) + src(2) + CHECK(count == 4) + end --- CHECK(not ok) --- end + do CASE "run effect callback twice" + local src = source(1) + local count = 0 --- do CASE "duplicate properties" --- local ok = pcall(function() --- create "TextLabel" { --- { --- Name = "foo" --- }, --- { --- Name = "bar" --- } --- } --- end) + effect(function() + count += 1 + src() + end) --- CHECK(not ok) + CHECK(count == 2) + src(2) + CHECK(count == 4) + end --- ok = pcall(function() --- create "TextLabel" { --- { --- Name = "foo", --- { --- Name = "bar" --- } --- } --- } --- end) + do CASE "indexes() error if primitive" + local src = source { 1 } --- CHECK(ok) --- end + local ok = pcall(function() + indexes(src, function() return 1 end) + end) --- do CASE "multiple cleanup per scope" --- local ok = pcall(function() --- cleanup(function() end) --- cleanup(function() end) --- end) + CHECK(not ok) + end --- CHECK(not ok) --- end --- end) + do CASE "values() error if duplicate" + local src = source { 1, 2, 1 } + + local ok = pcall(function() + values(src, function() return {} end) + end) + + CHECK(not ok) + end + + do CASE "duplicate properties" + local ok = pcall(function() + create "TextLabel" { + { + Name = "foo" + }, + { + Name = "bar" + } + } + end) + + CHECK(not ok) + + ok = pcall(function() + create "TextLabel" { + { + Name = "foo", + { + Name = "bar" + } + } + } + end) + + CHECK(ok) + end +end)) local ok = FINISH() if not ok then error("Tests failed", 0) end diff --git a/todo.md b/todo.md index c2ef0ec..0d59b74 100644 --- a/todo.md +++ b/todo.md @@ -1,19 +1,13 @@ # todo -- better error reporting and stack traces in strict mode - auto-enable of strict mode depending on compiler optimizaton level -- investigate if weak table iteration can be invalidated - property binding optimization - would no longer allow `cleanup()` usage in binding scopes - solution to nested reactivity, see: SolidJS stores -- SolidJS control flow components -- equality checking of derived sources - investigate performance of wide graphs - optimize child removal - implement from solid: - - Show - - Switch - - Dynamic - Portal - batch - optimize `indexes()` double-diffing +- define behavior of deriving a source within a derived source