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 7f9fe28..0e4a5d0 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -2,6 +2,30 @@
+## root() + +Creates and runs a function in a new reactive scope. + +- **Type** + + ```lua + function root(fn: (destroy: () -> ()) -> T...): T... + ``` + +- **Details** + + Creates a new root reactive scope, where creation and derivations of sources + can be tracked and properly disposed of. + + Returns the result of the given function. + + A function to destroy the root is passed into the callback, which will run + any cleanups and allow derived sources created to garbage collect. + + ::: warning + `fn()` cannot yield. + ::: + ## source() Creates a new source with the given value. @@ -14,10 +38,10 @@ Creates a new source with the given value. - **Details** - Calling the returned source with no arguments will return its stored value, - calling with arguments will set a new value. + Calling the returned source with no argument will return its stored value, + calling with an argument will set a new value. - Reading from the source from within any reactive scope will cause changes + Reading from the source from within a reactive scope will cause changes to that source to be tracked and anything depending on it to update. - **Example** @@ -30,44 +54,39 @@ Creates a new source with the given value. count(count() + 1) -- 1 ``` -## watch() +## effect() -Runs a callback on source update. +Runs a side-effect on source update. - **Type** ```lua - function watch(source: () -> ()): Unwatch - - type Unwatch = () -> () + function effect(callback: () -> ()) ``` - **Details** - The source callback is ran immediately to determine what states are - referenced. + The callback is ran immediately. Any time a source referenced in the callback is changed, the callback will be reran. - Also returns a function that when called, stops the watcher immediately. - ::: warning - `source()` cannot yield. + `callback()` cannot yield. ::: - **Example** ```lua - local state = source(1) + local num = source(1) - watch(function() - print(state()) + effect(function() + print(num()) end) -- prints 1 - state(state() + 1) + num(num() + 1) -- prints 2 ``` @@ -110,150 +129,4 @@ Derives a new source from existing sources. text() -- "count: 1" ``` -## indexes() - -Maps each index in a table source to an object. - -- **Type** - - ```lua - function indexes( - 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 new file mode 100644 index 0000000..58e2200 --- /dev/null +++ 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 4597364..22adba5 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -2,7 +2,7 @@ ## cleanup() -Runs a callback anytime a function scope is re-ran. +Runs a callback anytime a reactive scope is re-ran. - **Type** @@ -10,25 +10,12 @@ Runs a callback anytime a function scope is re-ran. function cleanup(callback: () -> ()) ``` -- **Details** - - The primary purpose of this function is to provide a means of cleaning up - side effects caused by source updates and `watch()` updates. - - The stack is inspected to find the function that calls `cleanup()`. The - callback passed is called anytime the caller is re-ran, and when the caller - finally garbage collects. - - ::: warning - Only one `cleanup()` call is allowed per function scope. - ::: - - **Example** ```lua local data = source(1) - watch(function() + effect(function() local label = create "TextLabel" { Text = data() } cleanup(function() @@ -53,7 +40,7 @@ Runs a callback anytime a function scope is re-ran. ## untrack() -Gets the value of a source without reactively tracking it. +Runs a given function where any sources read will not track its reactive scope. - **Type** @@ -82,3 +69,5 @@ Gets the value of a source without reactively tracking it. a(1) print(sum()) -- 2 ``` + +-------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index c0f1e7e..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 watchers twice when a source updates. +2. Run effects twice when a source updates. 3. Throw an error if yields occur where they are not allowed. 4. Checks for `indexes()` and `values()` returning primitive values. 5. Checks for duplicate nested properties at same depth. 6. Better error reporting and stack traces. 7. Checks for multiple `cleanup()` calls in the same function scope. -By rerunning sources and watchers, any side-effects are made more apparent. +By rerunning sources and effects, any side-effects are made more apparent. This also helps ensure that cleanups are being handled correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, diff --git a/docs/tut/crash-course/6-table-source.md b/docs/tut/control-flow/indexes.md similarity index 99% rename from docs/tut/crash-course/6-table-source.md rename to docs/tut/control-flow/indexes.md index 04c6dee..70864e7 100644 --- a/docs/tut/crash-course/6-table-source.md +++ b/docs/tut/control-flow/indexes.md @@ -1,4 +1,4 @@ -# Table Source +# Control Flow Vide has specific functions for dealing with sources that store a table value. 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 0197384..7b00dac 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -3,7 +3,7 @@ This is a brief tutorial designed to give you a quick run through the usage of Vide. -Vide is largely inspired by other UI libraries such as Solid and Fusion. +Vide is heavily inspired by [Solid](https://www.solidjs.com/). ## Why Vide? @@ -19,7 +19,29 @@ Some of the main focuses behind Vide's design choices: - Reducing the amount of imports needed for usage by leveraging Luau's syntax and semantics. - Being completely typecheckable. -- Flexibility, particularly with integrating other libraries and allowing users - to use their own patterns. -- A powerful reactive system that does not interfere with the lifetime of - instances. +- Flexibility with integrating other libraries and allowing users to use their + own patterns. +- Independence from instance lifetimes. +- A powerful reactive system that can update specific properties as a result of + state changes, updates are immediate with no diffing needed. + +## Structure Of A Vide App + +The entry point for all Vide apps is the `mount()` function. This function +sets up Vide's reactivity system. It takes and calls a function that should +create your entire app, and will apply its result to a target. + +In Vide, your app should be composed of functions, each function creates a +specific part of your app, and can be reused if needed. These functions are +called *components*. + +```lua + +local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "hi" } + } +end + +mount(App, game.StarterGui) +``` diff --git a/docs/tut/crash-course/8-actions.md b/docs/tut/crash-course/10-actions.md similarity index 100% rename from docs/tut/crash-course/8-actions.md rename to docs/tut/crash-course/10-actions.md diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index e180910..deb1683 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -1,62 +1,54 @@ -# Creating UI Elements +# Creating UI -Instances are created using [`create()`](../../api/creation.md#create). - -```lua -local vide = require(path_to_vide) -local create = vide.create -``` +Instances are created using `create()`. `create()` returns a constructor for a class which then takes a table of properties to assign when creating a new instance for that class. Luau allows us to omit parentheses `()` when calling functions with string or -table literals for brevity. +table literals which Vide takes advantage of for brevity. ```lua -local frame = create "Frame" { - Name = "Background", - Position = UDim2.fromScale(0.5, 0.5) -} -``` +local vide = require(vide) +local mount = vide.mount +local create = vide.create -String keys are treated as properties and integer keys are treated as child -instances. +local function App() + return create "ScreenGui" { + create "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromScale(0.4, 0.7), -```lua -create "ScreenGui" { - Parent = game.StarterGui, + create "TextLabel" { + Text = "hi" + }, - create "Frame" { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromScale(0.4, 0.7), + create "TextLabel" { + Text = "bye" + }, - create "TextLabel" { - Text = "hi" - }, + create "TextButton" { + Text = "click me", - create"TextLabel" { - Text = "bye" + Activated = function() + print "clicked!" + end + } } } -} +end + +mount(App, game.StarterGui) ``` -To connect to an event, just assign the event property a function. +Assign a value to a string key to set a property, and assign a value to a +number key to set a child. Events can be connected to by assigning a function +to a string key. -All event arguments are passed into the function. - -```lua -create "TextButton" { - Activated = function() - print "clicked!" - end -} -``` - -You can also use a form of aggregate initialization to create datatypes instead -of explicitly typing out the class name and constructor. +You can also use a shorthand to create datatypes instead of explicitly typing +out the class name and constructor. The table will be unpacked into the `.new()` +constructor of the property's type. ```lua create "Frame" { @@ -64,7 +56,3 @@ create "Frame" { UDim2 = { 0.5, 0, 0.5, 0 } } ``` - -When a property is assigned a table, Vide will inspect the type of the property -being assigned to, and call that type's default `new()` constructor with the -unpacked values from the assigned table. diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index ef97920..712ab9a 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -5,7 +5,10 @@ Components are custom-made reusable pieces of UI made from other pieces of UI. By using components you can make your application more modular and better organized. -```lua +```lua [Button.luau] +local vide = require(vide) +local create = vide.create + local function Button(props: { Position: UDim2, Text: string, @@ -20,29 +23,44 @@ local function Button(props: { Activated = props.Activated } end + +return Button ``` -Above is a simple example of a button component with its background color set to -a dark grey and with a fixed size. +```lua [App.luau] +local vide = require(vide) +local mount = vide.mount +local create = vide.create + +local Button = require(Button) + +local function App() + return create "ScreenGui" { + Button { + Position = UDim2.fromOffset(200, 200), + Text = "click me!", + + Activated = function() + print "clicked" + end + } + } +end + +mount(App, game.StarterGui) +``` + +Above is a simple example of a button component with a set color and size, +being reused across files. A single parameter `props` is used to pass properties to the component. -Creating instances of this button component is as simple as doing the below: - -```lua -local button = Button { - Position = UDim2.new(), - Text = "Click me!", - - Activated = function() - print "clicked" - end -} -``` Components allow you to *encapsulate* behavior. You can only modify the component in ways that you allow in the component. This also promotes code reusability. Anytime you want a new button all you do is call `Button {}` instead of creating and setting every property each time. +When changing the button in future, any changes to the button file will be +reflected anywhere the button is used throughout your app. This can be extended to much more complicated UI. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index 20ff4f1..cf7ceb2 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -4,33 +4,18 @@ core of reactivity in Vide, as updates to a source can automatically update properties or other sources depending on that source. -A source in Vide can be created using -[`source()`](../../api/reactivity-core.md#source). +A source in Vide can be created using `source()`. ```lua +local vide = require(vide) local source = vide.source -local count = source(0) -``` - -The value passed to `source()` is the initial value of the source. - -The value of a source can be set by calling it with an argument, and can be read -by calling it with no arguments. - -```lua -count(count() + 1) -- increment source by 1 -``` - -Below is an example of a stateful counter component. - -```lua -local function Counter(props: { Position: UDim2 }) +local function Counter() local count = source(0) return create "TextButton" { - Position = props.Position, - Size = UDim2.new(200, 50), + Position = UDim2.fromOffset(300, 300), + Size = UDim2.fromOffset(200, 50), Text = count, @@ -39,37 +24,28 @@ local function Counter(props: { Position: UDim2 }) end } end + +mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) ``` -Each call of `Counter {}` will create a new counter element, each with their own -independent count. +The value passed to `source()` is the initial value of the source. -Vide detects when you assign a function to a property. This is known -as *binding* and doing so will cause the property to *automatically* update -whenever a source in that function is updated, by rerunning the function and -assigning its return value. You can only bind non-event -properties, otherwise the function is connected as the event callback. - -This allows you as the programmer to not need to manually update GUI as the state -of your program changes. You just define how the data maps to UI, and Vide's -reactive system will surgically update any properties depending on sources that -are changed. - -Since sources are just functions, you can also pass external sources to -components like so: +The value of a source can be set by calling it with an argument, and can be read +by calling it with no arguments. ```lua -local function Text(p: { - Text: () -> string -}) - return create "TextLabel" { - Text = p.Text - } -end - -local text = source "hi" - -Text { - Text = text -} +count(count() + 1) -- increment count by 1 ``` + +Each call of `Counter {}` will create a new counter, each maintaining their +own count. + +When you assign a function to a non-event property, Vide will immediately run it +and check what sources were read from. When updating those sources again after, +this function will be re-ran and its return value applied to the property. +This is known as *binding* properties. + +This allows you as the programmer to not need to manually update UI as the state +of your program changes. You just define how the data maps to UI, and Vide's +reactive system will automatically update any properties depending on sources +that are updated. diff --git a/docs/tut/crash-course/5-derived-source.md b/docs/tut/crash-course/5-derived-source.md deleted file mode 100644 index a8b5b05..0000000 --- a/docs/tut/crash-course/5-derived-source.md +++ /dev/null @@ -1,102 +0,0 @@ -# Derived Source - -You can create new sources from existing sources. This is known as *deriving -sources*. - -A function that wraps a source effectively becomes a new source. If a source -used inside a function is updated, the whole function can be re-ran to recompute -its value. - -```lua -local count = source(0) - -local function text() - return "count: " .. count() -end - -create "TextLabel" { - Text = text -} -``` - -Sometimes when using expensive computations to derive state, you only want to -recalculate it once when a source state has changed - -If you wrap a source with a regular function, its value will be recomputed -every time you call that function. -[`derive()`](../../api/reactivity-core.md#derive) accepts a functions whose -return value will be cached, so that subsequent calls of this derived source -will return the same cached value until one of its input sources have changed. - -```lua -local derive = vide.derive -``` - -```lua -local count = source(0) - -local factorial = derive(function() - local n = 1 - for i = 2, count() do - n *= i - end - return n -end) -``` - -This can improve performance in cases where a source is read from multiple times -between recalculations, like in the example below: - -```lua -create "TextLabel" { - Text = function() - return "factorial squared: " .. factorial() * factorial() - end -} - -count(3) -- displays "factorial squared: 36" -count(4) -- displays "factorial squared: 576" -``` - -Vide knows what sources are being depended on by immediately running the -callback when deriving or binding sources. If a source is in a function but is -never referenced the first time it runs, Vide will not know to rerun the -function if that source changes. - -An example to watch out for is when using sources within branches: - -```lua -local condition = source(true) -local count1 = source(0) -local count2 = source(0) - -local text = function() - if condition() then - return "text: " .. count1() - else - return "text: " .. count2() - end -end -``` - -In the above case, only `count1` will be referenced, meaning `text` will not be -aware of `count2` even if the condition is later set to false. - -All sources to be tracked must be referenced the first time the function runs. - -```lua -local condition = source(true) -local count1 = source(0) -local count2 = source(0) - -local text = function() - local c1 = count1() - local c2 = count2() - - if condition() then - return "text: " .. c1 - else - return "text: " .. c2 - end -end -``` diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md new file mode 100644 index 0000000..67c1f58 --- /dev/null +++ b/docs/tut/crash-course/5-effect.md @@ -0,0 +1,45 @@ +# Effect + +An effect is a function that is run anytime a source updates. They are called +effects because they can produce side-effects when reacting to source changes. + +Effects are created using `effect()`. + +```lua +local vide = require(vide) +local source = vide.source +local effect = vide.effect + +local function Counter() + local count = source(0) + + effect(function() + print("count has updated to: " .. count()) + end) + + return create "TextButton" { + Position = UDim2.fromOffset(300, 300), + Size = UDim2.fromOffset(200, 50), + + Text = count, + + Activated = function() + count(count() + 1) + end + } +end + +mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) +``` + +This will print to the terminal anytime the count is changed. + +`effect()` creates an explicit side-effect. There are other side-effects in the +above code sample. The setting of `Text = count` creates another side-effect; +the updating of the Text property anytime the count is changed. + +All observable changes to the user are considered to be side-effects of the +reactive system. + +You should not update other sources using an effect. Improper usage can lead to +unecessary updates and infinite loops. diff --git a/docs/tut/crash-course/6-derived-source.md b/docs/tut/crash-course/6-derived-source.md new file mode 100644 index 0000000..01fe1c2 --- /dev/null +++ b/docs/tut/crash-course/6-derived-source.md @@ -0,0 +1,76 @@ +# Derived Source + +You can create new sources from existing sources. This is known as *deriving +sources*. + +A function that wraps a source effectively becomes a new source. If a source +used inside a function is updated, the whole function can be re-ran to recompute +its value. + +```lua +local vide = require(vide) +local source = vide.source + +local function Counter() + local count = source(0) + + local function doubled() + return count() * 2 + end + + return create "TextButton" { + Position = UDim2.fromOffset(300, 300), + Size = UDim2.fromOffset(200, 50), + + Text = doubled, + + Activated = function() + count(count() + 1) + end + } +end + +mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) +``` + +Now the counter will increment in 2s each time it is clicked. + +Sometimes when using expensive computations to derive state, you only want to +recalculate it once when a source state has changed. Although not needed in +most cases, you can use `derive()` to create a new source that will cache its +value, only recomputing when an input source has changed. + +```lua +local vide = require(vide) +local source = vide.source +local derive = vide.derive + +local function Counter() + local count = source(0) + + local factorial = derive(function() + local n = 1 + for i = 2, count() do + n *= i + end + return n + end) + + return create "TextButton" { + Position = UDim2.fromOffset(300, 300), + Size = UDim2.fromOffset(200, 50), + + Text = function() + return factorial() + factorial() + factorial() + end, + + Activated = function() + count(count() + 1) + end + } +end +``` + +This can improve performance in cases where a source is read from multiple times +between recalculations. In the above example, the factorial is only ever +calculated once each time the count changes. diff --git a/docs/tut/crash-course/7-cleanup.md b/docs/tut/crash-course/7-cleanup.md new file mode 100644 index 0000000..3e5525d --- /dev/null +++ b/docs/tut/crash-course/7-cleanup.md @@ -0,0 +1,40 @@ +# Cleanup + +Sometimes you may need to do some cleanup when destroying a component or after +a side-effect from a source update. Vide provides a function `cleanup()` which +is used to register a cleanup callback for the next time the reactive scope +it is called in re-runs. + +```lua +local vide = require(vide) +local source = vide.source +local cleanup = vide.cleanup + +local function Timer() + local count = source(0) + + local con = game:GetService("RunService").Heartbeat:Connect(function(dt) + count(count() + dt) + end) + + cleanup(function() + con:Disconnect() + end) + + return create "TextButton" { + Position = UDim2.fromOffset(300, 300), + Size = UDim2.fromOffset(200, 50), + + Text = function() + return "seconds: " .. count() + end, + } +end + +mount(function() return create "ScreenGui" { Timer {} } end, game.StarterGui) +``` + +In the above example, this allows us to disconnect the heartbeat connection +when the timer component is destroyed, whether that is from unmounting the app +or if it is dynamically created by a control-flow function, which will be +covered next. diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/8-control-flow.md new file mode 100644 index 0000000..f590145 --- /dev/null +++ b/docs/tut/crash-course/8-control-flow.md @@ -0,0 +1,95 @@ +# Control Flow + +Eventually you will need a way to dynamically create and destroy UI elements +resulting from state changes. Vide provides functions to help you do this, +known as *control flow* functions. + +These functions return a new source, which holds the instances to be displayed. +These sources can be assigned as children, meaning the displayed children +will update when the input source updates. + +One of these functions is `switch()`, used to conditionally show one of a set of +components. + +```lua +local vide = require(vide) +local source = vide.source +local switch = vide.switch + +local function ToggleButton(p: { + Text: string, + Toggle: (boolean) -> boolean +}) + return create "TextButton" { + Size = UDim2.fromOffset(300, 300), + Text = p.Text, + Activated = function() + p.Toggle(not p.Toggle()) + end + } +end + +local loggedIn = source(false) + +local function LoginMenu() + return Frame { + switch(loggedIn) { + [true] = function() + return ToggleButton { Text = "Log out", Toggle = loggedIn } + end, + + [false] = function() + return ToggleButton { Text = "Log in", Toggle = loggedIn } + end + } + } +end + +mount(function() return create "ScreenGui" { LoginMenu {} } end, game.StarterGui) +``` + +Above is an example of using a switch to create a login menu. Each time +`loggedIn` toggles, the current button will be destroyed, and a new button +created, which the text to represent the current action, to log in or log out. + +Another control flow function, `indexes()`, is used to create elements from an +input table. + +Often, you will have a table of values that will be displayed in a similar +manner. Rather than manually looping over each value to generate a corresponding +UI element, `indexes()` can autmatically run a transform function for each +index and value, generating a UI element. + +```lua +local todoList = { + "Finish the crash course", + "Star vide's GitHub" +} + +local elements = indexes(todoList, function(todo, i) + return create "TextLabel" { + Text = function() + return i .. ": " .. todo() + end, + + LayoutOrder = i + } +end) + +mount(function() + return create "ScreenGui" { + create "UIListLayout" {}, elements + } +end, game.StarterGui) +``` + +For each unique index in the passed table, the transform function will be called +with 1. a source containing the value of the index, 2. the index itself. + +When the value at an index is changed, the function is not reran. Instead, the +given source is updated instead. + +`indexes()` is said to map each *index* in a table to a UI element, each index +has a single corresponding element. + +An element is only destroyed if the value of an index is set to `nil`. diff --git a/docs/tut/crash-course/7-nested-properties.md b/docs/tut/crash-course/9-property-nesting.md similarity index 100% rename from docs/tut/crash-course/7-nested-properties.md rename to docs/tut/crash-course/9-property-nesting.md 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 88f4a22..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 @@ -17,7 +17,7 @@ local function action(callback: (Instance) -> (), priority: number?): Action setmetatable(t :: any, ActionMT) - return t + return table.freeze(t) end return function() diff --git a/src/apply.luau b/src/apply.luau index 9bc5d25..d0bccf7 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -10,11 +10,16 @@ local _, is_action = require(script.Parent.action)() local graph = require(script.Parent.graph) type Node = graph.Node +type Array = { V } +type Map = { [K]: V } + -- buffer of event -> callback to connect after properties are set -local event_buffer: { [string]: () -> () } = {} +local event_buffer = {} :: Map ()> -- buffer of priority -> callback to run after events are connected -local action_buffers = {} :: { { (Instance) -> () } } +local action_buffers = {} :: Map ()>> + +-- lazily create buffers on nil index setmetatable(action_buffers :: any, { __index = function(_, i: number) action_buffers[i] = {} @@ -22,8 +27,9 @@ setmetatable(action_buffers :: any, { end }) --- cache used in strict mode to detect duplicate property sets at same nesting levels -local nested_debug_cache: { [number]: { [string]: true } } = {} +-- cache in strict mode to detect duplicate property set at same nesting level +local nested_debug_cache = {} :: Map> + setmetatable(nested_debug_cache :: any, { __index = function(_, i: number) nested_debug_cache[i] = {} @@ -31,28 +37,30 @@ setmetatable(nested_debug_cache :: any, { end }) --- a stack used in place of a recursive function to process nesting layers one at a time --- enforces the behavior of deeper-nested properties taking precedence of lesser-nested ones --- each nested table occupies two indexes, reference to table itself and the depth number --- e.g. props = { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } +-- use stack instead of recursive function to process nested layers one at time +-- deeper-nested properties take precedence over shallower-nested ones +-- each nested layer occupies two indexes: 1. table ref 2. nested depth +-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } local nested_stack = {} :: { {} | number } -- todo: solution without manual updating of this table --- map of datatype names to class default constructor for aggregate initialization +-- map of datatype names to class default constructor for aggregate init local aggregates = {} for i, v in next, { - Vector2 = Vector2, - UDim2 = UDim2, + CFrame = CFrame, + Color3 = Color3, UDim = UDim, - Rect = Rect, - Color3 = Color3 + UDim2 = UDim2, + Vector2 = Vector2, + Vector3 = Vector3, + Rect = Rect } do aggregates[i] = v.new end -- processes a potentially nested table of values to assign to an instance -local function process_nested(instance: Instance, properties: { [unknown]: unknown }) +local function process_props(instance: Instance, properties: Map) local strict = flags.strict table.clear(nested_stack) @@ -73,27 +81,27 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno if type(value) == "table" then -- attempt aggregate init local ctor = aggregates[typeof((instance :: any)[property])] if ctor == nil then - throw(`cannot aggregate construct type {typeof(value)} for property {property}`) + throw(`cannot aggregate type {typeof(value)} for property {property}`) end (instance :: any)[property] = ctor(unpack(value :: {})) elseif type(value) == "function" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then event_buffer[property] = value :: () -> () -- add event to buffer else - bind.property(instance, property, value :: () -> ()) -- bind source + bind.property(instance, property, value :: () -> ()) -- bind property end else (instance :: any)[property] = value -- set property end elseif type(property) == "number" then if type(value) == "function" then - bind.children(instance, value :: () -> { Instance }) -- bind children + bind.children(instance, value :: () -> Instance | Array) -- bind children elseif type(value) == "table" then if is_action(value) then table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer else - table.insert(nested_stack, depth + 1) -- push table to stack for later processing table.insert(nested_stack, value :: {}) + table.insert(nested_stack, depth + 1) -- push table to stack for later processing end else (value :: Instance).Parent = instance -- parent child @@ -102,8 +110,8 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno end -- pop next nested table off stack - properties = table.remove(nested_stack) :: {} depth = table.remove(nested_stack) :: number + properties = table.remove(nested_stack) :: {} until not properties end @@ -121,14 +129,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end -- process all properties for immediate setting or buffering - process_nested(instance, properties) + process_props(instance, properties) -- connect buffered events for event, fn in next, event_buffer do (instance :: any)[event]:Connect(fn) end - -- run buffered actions respecting their priorities + -- run buffered actions for _, buffer in next, action_buffers do for _, callback in next, buffer do callback(instance) @@ -138,7 +146,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown -- finally set parent if any if parent then if type(parent) == "function" then - error("cannot set parent to state") + bind.parent(instance, parent :: () -> Instance) else instance.Parent = parent :: Instance end diff --git a/src/bind.luau b/src/bind.luau index 7a08292..bcd0701 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,152 +1,130 @@ if not game then script = require "test/relative-string" end -local warn = game and warn or print :: never local throw = require(script.Parent.throw) +local trace = require(script.Parent.trace) local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node -local set_effect = graph.set_effect -local capture = graph.capture +local create_node = graph.create_node +local get_scope = graph.get_scope +local evaluate_node = graph.evaluate_node +local set_owner = graph.set_owner ---[[ - -Roblox instances in Luau are referenced using a kind of userdata proxy, -this proxy can be garbage collected independently from the actual instance, even -if the instance is still parented. Since reactive bindings allow the garbage -collection of instances, this proxy can can garbage collected while the instance -is still parented, causing the binding to be lost and no longer update the -instance on changes. - -Vide's solution to this is to hold the proxy in memory as long as the instance -is parented to the datamodel by using `GetPropertyChanged("Parent")` to add or -remove the proxy from a table whose sole purpose is to strongly reference -proxies. - -todo: investigate behavior in case B is parented to A, and A has no parent or reference, and B has a binding. - -]] - --- holds parented instance proxies in memory -local hold: { Instance? } = {} - --- weakly references instances with properties bound -local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any - --- unique binding id -local bind_count = 0 - --- todo: replace with throw's method -local root do - local src = debug.info(1, "s") - root = string.sub(src, 1, #src - 5) -end - -local function traceback(skips: number) -- ensures trace begins outside of any vide library file - local s = 1 - - repeat - s += 1 - local path = debug.info(s, "s") - - local found = not string.find(path, root) - - if found then - skips -= 1 - end - until found and skips < 0 - - return debug.traceback(nil, s) -end - -function bind(instance: Instance, property: string, setter: (Instance) -> ()) +function create_binding(updater: (T) -> T, binding: T) if flags.strict then - -- wrap setter in function with stack inspection for better error msgs - local fn = setter - local bind_trace = traceback(0) - setter = function(instance) - local ok, err: string? = xpcall(fn, function(err: string) - return err .. "\nsource updated at: " .. traceback(2) - end, instance) - if not ok then warn(`error occured updating {property}: {err}bound at: {bind_trace}`) end + -- track bind creation trace + local fn = updater + local bind_trace = debug.traceback(nil, trace()-1) + updater = function(...) + local ok, result = xpcall(fn, function(err: string) + return err + end, ...) + + if not ok then + local btype = + if (binding :: any).property then (binding :: any).property + elseif (binding :: any).parent then "Parent" + else "children" + error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0) + end + + return result end end - -- run setter to capture any nodes being depended on - local nodes = (capture(setter :: () -> unknown, instance)) - - -- register the setter as a side-effect of each node - for _, node in next, nodes do - set_effect(node, setter, instance) - end - - -- get binding id - bind_count += 1 - local bind_id = bind_count - -- store reference of instance proxy without preventing gc - weak[bind_id] = instance + local owner = get_scope() + if not owner then + throw("cannot bind property in non-reactive scope") + end; assert(owner) + + local node = create_node(binding, updater) - local function ref() - local _ = setter -- prevent gc of nodes being depended on - local instance = weak[bind_id] :: Instance + set_owner(node, owner) + evaluate_node(node) +end - -- keep proxy in memory if instance is still parented - hold[bind_id] = instance.Parent and instance or nil +type PropertyBinding = { + instance: Instance, + property: string, + source: () -> unknown +} + +local function update_property(p: PropertyBinding) + (p.instance :: any)[p.property] = p.source() + return p +end + +type ParentBinding = { + instance: Instance, + parent: () -> Instance +} + +local function update_parent(p: ParentBinding) + p.instance.Parent = p.parent() + return p +end + +type ChildrenBinding = { + instance: Instance, + cur_children_set: { [Instance]: true }, + new_children_set: { [Instance]: true }, + children: () -> Instance | { Instance } +} + +local function update_children(p: ChildrenBinding) + local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update + local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update + + local new_children = p.children() -- all (and only) children that should be parented after this update + + if type(new_children) ~= "table" then + new_children = { new_children } end - ref() - instance:GetPropertyChangedSignal("Parent"):Connect(ref) -end - -local function bind_property(instance: Instance, property: string, fn: () -> unknown) - bind(instance, property, function(instance_weak: any) - instance_weak[property] = fn() - end) -end - -local function bind_parent(instance: Instance, fn: () -> Instance?) - instance.Destroying:Connect(function() - instance = nil :: any -- allow gc when destroyed - end) - - bind(instance, "Parent", function(instance) - local _ = instance -- state will strongly reference instance when parent is bound - instance.Parent = fn() - end) -end - -local function bind_children(parent: Instance, fn: () -> { Instance }) - local current_child_set: { [Instance]: true } = {} -- cache of all children parented before update - local new_child_set: { [Instance]: true } = {} -- cache of all children parented after update - - bind(parent, "Children", function(parent_weak) - local new_childs = fn() -- all (and only) children that should be parented after this update - if new_childs and type(new_childs) ~= "table" then - throw(`Cannot parent instance of type { type(new_childs) } `) - end - - if new_childs then - for _, child in next, new_childs do - new_child_set[child] = true -- record child set from this update - if not current_child_set[child] then - child.Parent = parent_weak -- if child wasn't already parented then parent it - else - current_child_set[child] = nil -- remove child from cache if it was already in cache - end + if new_children then + for _, child in next, new_children :: { Instance } do + new_child_set[child] = true -- record child set from this update + if not cur_children_set[child] then + child.Parent = p.instance -- if child wasn't already parented then parent it + else + cur_children_set[child] = nil -- remove child from cache if it was already in cache end end + end - for child in next, current_child_set do - child.Parent = nil -- unparent all children that weren't in the new children set - end + for child in next, cur_children_set do + child.Parent = nil -- unparent all children that weren't in the new children set + end - table.clear(current_child_set) -- clear cache, preserve capacity - current_child_set, new_child_set = new_child_set, current_child_set - end) + table.clear(cur_children_set) -- clear cache, preserve capacity + p.cur_children_set, p.new_children_set = new_child_set, cur_children_set + + return p end return { - property = bind_property, - parent = bind_parent, - children = bind_children, + property = function(instance, property, source) + return create_binding(update_property, { + instance = instance, + property = property, + source = source + }) + end, + + parent = function(instance, parent) + return create_binding(update_parent, { + instance = instance, + parent = parent + }) + end, + + children = function(instance, children) + return create_binding(update_children, { + instance = instance, + cur_children_set = {}, + new_children_set = {}, + children = children + }) + end } diff --git a/src/changed.luau b/src/changed.luau new file mode 100644 index 0000000..5d439c6 --- /dev/null +++ b/src/changed.luau @@ -0,0 +1,18 @@ +if not game then script = require "test/relative-string" end + +local action = require(script.Parent.action)() +local cleanup = require(script.Parent.cleanup) + +local function changed(property: string, callback: (T) -> ()) + return action(function(instance) + local con = instance:GetPropertyChangedSignal(property):Connect(function() + callback((instance :: any)[property]) + end) + + cleanup(function() + con:Disconnect() + end) + end) +end + +return changed diff --git a/src/cleanup.luau b/src/cleanup.luau index d3da68a..05a516f 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,129 +1,18 @@ if not game then script = require "test/relative-string" end -local flags = require(script.Parent.flags) local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +local get_scope = graph.get_scope +local add_cleanup = graph.add_cleanup ---[[ - -Cleanups associate a callback with an arbitrary value with an unknown lifetime. -Anytime a new callback is registered with a value that already has one registered, -the registered callback is ran and then replaced with the new one. - -When the value is eventually garbage collected, Vide checks for callbacks -without an associated value, which it will then run and clear, recycling its -cleanup id. - -By default the arbitrary value is the function object that calls `cleanup()`. -There are exceptions such as with `indexes()` and `values()` where the -arbitrary value is manually set to be the new source created instead of the -caller, as the same caller can be used to create multiple new objects. - -todo: remove need for ref to id maps? - -]] - --- maps a ref to cleanup id -local ref_to_id = {} :: { [string]: number } --- maps a cleanup id to a ref -local id_to_ref = {} :: { [number]: string } --- array of all cleanup callbacks -local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense --- weak array of all cleanup lifetimes -local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse -setmetatable(cleanup_lifetime :: any, { __mode = "v" }) - --- detects in strict mode when multiple cleanups are registered in the same scope -local debug_caller_to_line = {} :: { [() -> ()]: number } -setmetatable(debug_caller_to_line, { __mode = "k" }) - --- when active, cleanup callbacks are not automatically registered but are --- added to an array for manual registering internally -local manual_mode = { - caller = false :: false | () -> (), - callbacks = {} :: { () -> () } -} - --- todo: rare case where mem address is reused by another function --- does this case handle itself? - --- registers a callback with the given lifetime using the given ref -local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ()) - local id = ref_to_id[ref] - - if id then -- invoke previously registered callback then register new one - cleanup_callbacks[id]() - cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil - else -- no previously registered callback, add and register new one - id = #cleanup_callbacks + 1 - ref_to_id[ref] = id - id_to_ref[id :: any] = ref -- todo - cleanup_lifetime[id :: any] = lifetime -- todo - end - - cleanup_callbacks[id] = callback -end - --- registers a callback with its caller as the lifetime, and caller address as the ref local function cleanup(callback: () -> ()) - local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup` + local scope = get_scope() + if not scope then + throw("cannot cleanup in a non-reactive scope") + end; assert(scope) - if flags.strict then - local line = debug.info(2, "l") - local cur_line = debug_caller_to_line[lifetime] - if cur_line and cur_line ~= line then - throw "only one cleanup call is allowed per function scope" - end - debug_caller_to_line[lifetime] = line - end - - if manual_mode.caller == lifetime then - table.insert(manual_mode.callbacks, callback) - else - local ref = tostring(lifetime) - cleanup_ref(ref, lifetime, callback) - end + add_cleanup(scope, callback) end -local function clean_garbage() - for id = #cleanup_callbacks, 1, -1 do - if cleanup_lifetime[id] == nil then -- lifetime was garbage collected - local callback = cleanup_callbacks[id] +return cleanup - do -- swap and pop - local max_id = #cleanup_callbacks - - cleanup_callbacks[id] = cleanup_callbacks[max_id] - cleanup_callbacks[max_id] = nil - - cleanup_lifetime[id] = cleanup_lifetime[max_id] - cleanup_lifetime[max_id] = nil - - local ref = id_to_ref[id] - local max_ref = id_to_ref[max_id] - - id_to_ref[id] = max_ref - id_to_ref[max_id] = nil - - ref_to_id[max_ref] = id - ref_to_id[ref] = nil - end - - local ok, err: string? = pcall(callback) - if not ok then warn(`error occured during cleanup: {err}`) end - end - end -end - -local manual_cleanup_mode = function(caller: () -> ()?) - if caller == nil then - local clone = table.clone(manual_mode.callbacks) - manual_mode.caller = false - table.clear(manual_mode.callbacks) - return clone - else - manual_mode.caller = caller - end - return manual_mode.callbacks -end :: ( (caller: (...any) -> ()) -> () ) & ( (nil) -> { () -> () } ) - -return function() return cleanup, clean_garbage, manual_cleanup_mode, cleanup_ref end 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 4d08437..29b915a 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,15 +1,28 @@ if not game then script = require "test/relative-string" end +local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) -local create = graph.create -local capture_and_link = graph.capture_and_link +local create_node = graph.create_node +local set_owner = graph.set_owner +local track = graph.track +local get_scope = graph.get_scope +local evaluate_node = graph.evaluate_node -local function derive(fn: () -> T): () -> T - local node, read_node_value = create((false :: any) :: 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) - node.cache = capture_and_link(node, fn) + local node = create_node(false :: any, source) - return read_node_value + set_owner(node, owner) + evaluate_node(node) + + return function() + track(node) + return node.cache + end end return derive diff --git a/src/effect.luau b/src/effect.luau new file mode 100644 index 0000000..bc53660 --- /dev/null +++ b/src/effect.luau @@ -0,0 +1,22 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +local create_node = graph.create_node +local get_scope = graph.get_scope +local evaluate_node = graph.evaluate_node +local set_owner = graph.set_owner + +local function effect(callback: (T) -> T, initial_value: T) + local owner = get_scope() + if not owner then + throw("cannot create effect in non-reactive scope") + end; assert(owner) + + local node = create_node(initial_value, callback) + + set_owner(node, owner) + evaluate_node(node) +end + +return effect :: ((callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ()) diff --git a/src/graph.luau b/src/graph.luau index 029067c..20140bd 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -3,25 +3,24 @@ if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) -export type Node = { +export type StartNode = { cache: T, - derive: () -> T, - effects: { [(unknown) -> ()]: unknown }, -- weak values - children: { Node } | false -- weak values + [number]: Node } --- flag used to detect when node reference capturing is active -local reff = false --- array of all nodes referenced since above flag was set -local refs = {} :: { Node } +export type Node = { + cache: T, + effect: ((T) -> T) | false, + cleanups: { () -> () } | false, + parents: { owner: StartNode?, [number]: StartNode }, + [number]: Node +} -local WEAK_VALUES = { __mode = "v" } -local EVALUATION_ERR = "error while evaluating source:\n\n" - -setmetatable(refs :: any, WEAK_VALUES) +-- reactive scope stack +local scopes = { n = 0 } :: { [number]: Node, n: number } -- runs a given callback in a context that Luau does not allow yielding in -local check_for_yield: (fn: (T...) -> unknown, T...) -> () do +local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do local t = { __mode = "kv" } setmetatable(t, t) @@ -32,149 +31,197 @@ local check_for_yield: (fn: (T...) -> unknown, T...) -> () do fn(unpack(args)) end - local ok, err = pcall(function() + local ok, err: string? = pcall(function() local _ = -t end) - if not ok then - if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then - throw(EVALUATION_ERR .. "cannot yield when deriving node in watcher") - else - throw(EVALUATION_ERR .. err) - end - end + return ok, if err == "attempt to yield across metamethod/C-call boundary" + or err == "thread is not yieldable" then "yield occured" + else err end end ---[[ - -Each node side-effect is registered with a corresponding weak key. -This makes the lifetime of the side-effect tied to the key's. -The main usecase of this is to tie a side-effect to an instance, while allowing -the instance to be garbage collected even when the node still exists. - -The weak key is passed as an argument to its side-effect callback. - -]] - -local function set_effect(node: Node, fn: (T) -> (), key: T) - node.effects[fn :: () -> ()] = key +local function get_scope(): Node? + return scopes[scopes.n] end -local function run_effects(node: Node) - if flags.strict then -- run effects twice if strict - for effect, key in next, node.effects do - effect(key) - effect(key) - end +local function add_child(parent: StartNode, child: Node) + table.insert(parent, child) + table.insert(child.parents, parent) +end + +local function set_owner(node: Node, owner: Node) + node.parents.owner = owner + table.insert(owner, node) +end + +local function open_scope(node: Node) + local n = scopes.n + 1 + scopes.n = n + scopes[n] = node +end + +local function close_scope() + local n = scopes.n + scopes.n = n - 1 + scopes[n] = nil +end + +local function add_cleanup(node: Node, cleanup: () -> ()) + if node.cleanups then + table.insert(node.cleanups, cleanup) else - for effect, key in next, node.effects do - effect(key) + node.cleanups = { cleanup } + end +end + +local function run_cleanups(node: Node) + if node.cleanups then + for _, fn in next, node.cleanups do + local ok, err: string? = pcall(fn) + if not ok then throw(`cleanup error: {err}`) end + end + table.clear(node.cleanups) + end +end + +local function remove_child(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 +end + +local function unparent(node: Node) + local parents = node.parents + + for i, parent in ipairs(parents) do + remove_child(parent, node) + parents[i] = nil + end +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 + end + + while node[1] do destroy(node[1]) end +end + +local update_queue = {} :: { Node } + +local function evaluate_node(node: Node) + local cur_value = node.cache + + if flags.strict then + run_cleanups(node) + open_scope(node) + + local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) + + close_scope() + + if not ok then throw(err :: string) end + end + + run_cleanups(node) -- todo: move in scope? + open_scope(node) + + local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) + + close_scope() + + if not ok then + table.clear(update_queue) + throw(`side-effect error from source update\n{new_value}`) + end + + node.cache = new_value + + return cur_value ~= new_value -- node has changed value +end + +local function update_from(node: StartNode, n0: number) + if not node[1] then return end + + local n = n0 + + -- unparent all children and queue for eval + do + local child = node[1] + while child do -- todo: case where child in owner context + unparent(child) + + n += 1 + update_queue[n] = child + + child = node[1] end end -end --- retrieves a node's cached value --- add self to refs if ref capture flag is enabled -local function get(node: Node): T - if reff then table.insert(refs, node) end - return node.cache -end + -- evaluate all queued children + for i = n0 + 1, n do + local child = update_queue[i] + if not child.effect then continue end --- links two nodes as parent-child -local function set_child(parent: Node, child: Node) - if parent.children then - table.insert(parent.children, child) - else - parent.children = { child } - setmetatable(parent.children :: any, WEAK_VALUES) - end -end - --- runs node effects, recalculates descendants and runs descendant effects -local function update(node: Node) - run_effects(node) - if node.children then - local strict = flags.strict - - for _, child in node.children do - if strict then check_for_yield(child.derive) end - child.cache = child.derive() - update(child) + if evaluate_node(child) then + update_from(child, n) end + + update_queue[i] = false :: any -- false instead of nil to avoid sparse end end --- sets a node's cached value and updates all descendants -local function set(node: Node, value: T) - node.cache = value - update(node) +local function update(node: StartNode) + update_from(node, 0) end --- links two nodes as parent-child with a function to compute a new value for child -local function link(parent: Node, child: Node, derive: () -> T) - child.derive = derive - set_child(parent, child) -end - --- detect what nodes were referenced in the given callback and returns them in an array -local function capture(fn: (U?) -> T, arg: U?): ({ Node }, T) - if reff then throw("recursive capture detected") end - - if flags.strict then check_for_yield(fn, arg) end - - table.clear(refs) - reff = true - - local ok: boolean, result: T|string - - if arg == nil then - ok, result = pcall(fn) - else - ok, result = pcall(fn, arg) +local function track(node: StartNode) + local scope = get_scope() + if scope and scope.effect then -- do not track nodes with no effect + add_child(node, scope) end - - reff = false - - if not ok then throw(EVALUATION_ERR .. result :: string) end - - return refs, result :: T end --- captures and links any detected nodes -local function capture_and_link(child: Node, fn: () -> T): T - local nodes, value = capture(fn, nil) - - child.derive = fn - for _, parent: Node in next, nodes do - set_child(parent, child) - end - - return value :: T -end - -local function create(value: T): (Node, () -> T) - local node = { +local function create_node(value: T, effect: false | (T) -> T): Node + return { cache = value, - derive = function() return nil :: any end, - effects = setmetatable({}, WEAK_VALUES) :: any, - children = false :: false + effect = effect, + cleanups = false, + parents = {}, } +end - local function read_node_value() - return get(node) - end +local function create_start_node(value: T): StartNode + return { cache = value } +end - return node, read_node_value +local function get_children(node: Node): { Node } + return { unpack(node) } :: { Node } end return table.freeze { - set_effect = set_effect, - get = get, - set = set, - link = link, - capture = capture, - capture_and_link = capture_and_link, - create = create :: ((value: T) -> (Node, () -> T)) & (() -> (Node, () -> T)), - refs = refs + open_scope = open_scope, + close_scope = close_scope, + evaluate_node = evaluate_node, + get_scope = get_scope, + add_cleanup = add_cleanup, + set_owner = set_owner, + destroy = destroy, + run_cleanups = run_cleanups, + track = track, + update = update, + add_child = add_child, + create_node = create_node, + create_start_node = create_start_node, + get_children = get_children, + scopes = scopes } diff --git a/src/init.luau b/src/init.luau index 416ac75..8958dd9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -5,16 +5,20 @@ if not game then script = require "test/relative-string" end +local root = require(script.root) +local mount = require(script.mount) local create = require(script.create) local apply = require(script.apply) local source = require(script.source) -local watch = require(script.watch) -local cleanup, clean_garbage = require(script.cleanup)() +local effect = require(script.effect) +local cleanup = require(script.cleanup) local untrack = require(script.untrack) local derive = require(script.derive) +local switch = require(script.switch) local indexes, values = require(script.maps)() local spring, update_springs = require(script.spring)() local action = require(script.action)() +local changed = require(script.changed) local throw = require(script.throw) local flags = require(script.flags) @@ -28,13 +32,6 @@ local function step(dt: number) update_springs(dt) - if game then - debug.profileend() - debug.profilebegin("VIDE GARBAGE CLEANUP") - end - - clean_garbage() - if game then debug.profileend() debug.profileend() @@ -47,10 +44,13 @@ end) local vide = { -- core + root = root, + mount = mount, create = create, source = source, - watch = watch, + effect = effect, derive = derive, + switch = switch, indexes = indexes, values = values, @@ -63,6 +63,7 @@ local vide = { -- actions action = action, + changed = changed, -- flags strict = (nil :: any) :: boolean, diff --git a/src/maps.luau b/src/maps.luau index 977cfe2..da5c5d7 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -1,16 +1,20 @@ if not game then script = require "test/relative-string" end --- todo: more testing needed regarding `cleanup()` usage - local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) -local _, _, manual_cleanup_mode, cleanup_ref = require(script.Parent.cleanup)() type Node = graph.Node -local create = graph.create -local set = graph.set -local capture = graph.capture -local link = graph.link +type StartNode = graph.StartNode +local create_node = graph.create_node +local create_start_node = graph.create_start_node +local set_owner = graph.set_owner +local track = graph.track +local update = graph.update +local get_scope = graph.get_scope +local open_scope = graph.open_scope +local close_scope = graph.close_scope +local evaluate_node = graph.evaluate_node +local destroy = graph.destroy type Map = { [K]: V } @@ -23,17 +27,22 @@ local function check_primitives(t: {}) end end --- todo: optimize output array local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } + local owner = get_scope() + if not owner then + throw("cannot derive in non-reactive scope") + end; assert(owner) + + local subowner = create_node(false, false) + set_owner(subowner, owner) + local input_cache = {} :: Map local output_cache = {} :: Map - local input_nodes = {} :: Map> + local input_nodes = {} :: Map> local remove_queue = {} :: { K } - local output_array = {} :: { VO } + local scopes = {} :: Map> - local cleanups = {} :: Map () }> - - local function recompute(data) + local function update_children(data) -- queue removed values for i in next, input_cache do if data[i] == nil then @@ -43,41 +52,58 @@ local function indexes(input: () -> Map, transform: (() -> VI, -- remove queued values for _, i in next, remove_queue do - for _, callback in next, cleanups[i] do - callback() -- todo: pcall - end + destroy(scopes[i]) input_cache[i] = nil output_cache[i] = nil input_nodes[i] = nil - cleanups[i] = nil + scopes[i] = nil end table.clear(remove_queue) + open_scope(subowner) + -- process new or changed values for i, v in next, data do local cv = input_cache[i] if cv ~= v then - if cv == nil then - manual_cleanup_mode(transform) + if cv == nil then -- create new scope and run transform + local scope = create_node(false, false) + scopes[i] = scope :: Node - local node, get_value = create(v) + local node = create_start_node(v) + + set_owner(scope, subowner) + open_scope(scope) + + local ok, result = pcall(transform, function() + track(node) + return node.cache + end, i) + + close_scope() + + if not ok then + close_scope() -- subowner scope + error(result, 0) + end + input_nodes[i] = node - output_cache[i] = transform(get_value, i) - input_cache[i] = v - - cleanups[i] = manual_cleanup_mode(nil) - else - set(input_nodes[i], v) - input_cache[i] = v + output_cache[i] = result + else -- update source + input_nodes[i].cache = v + update(input_nodes[i]) end + + input_cache[i] = v end end - -- output elements - table.clear(output_array) + close_scope() + + local output_array = table.create(#scopes) for _, v in next, output_cache do table.insert(output_array, v) end @@ -86,43 +112,34 @@ local function indexes(input: () -> Map, transform: (() -> VI, return output_array end - local output, read_output_value = create(nil :: any) - - local function derive() - return recompute(input()) - end - - local nodes, value = capture(input) - - for _, node in next, nodes do - link(node, output, derive) - end - - output.cache = recompute(value) - - cleanup_ref(tostring(output), output, function() - for _, callbacks in next, cleanups do - for _, callback in next, callbacks do - callback() -- todo: pcall - end - end + local node = create_node(false :: any, function() + return update_children(input()) end) - return read_output_value + evaluate_node(node) + + return function() + track(node) + return node.cache + end end --- todo: optimize output array local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } + local owner = get_scope() + if not owner then + throw("cannot derive in non-reactive scope") + end; assert(owner) + + local subowner = create_node(false, false) + set_owner(subowner, owner) + local cur_input_cache_up = {} :: Map local new_input_cache_up = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local output_array = {} :: { VO } + local input_nodes = {} :: Map> + local scopes = {} :: Map> - local cleanups = {} :: Map () }> - - local function recompute(data: Map) + local function update_children(data: Map) local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up if flags.strict then @@ -134,6 +151,8 @@ local function values(input: () -> Map, transform: (VI, () -> cache[v] = true end end + + open_scope(subowner) -- process data for i, v in next, data do @@ -141,71 +160,73 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] - if cv == nil then - manual_cleanup_mode(transform) + if cv == nil then -- create new scope and run transform + local scope = create_node(false, false) + scopes[v] = scope :: Node - local node, get_value = create(i) - input_nodes[v] = node - output_cache[v] = transform(v, get_value) + local node = create_start_node(i) + + set_owner(scope, subowner) + open_scope(scope) + + local ok, result = pcall(transform, v, function() + track(node) + return node.cache + end) + + close_scope() - cleanups[v] = manual_cleanup_mode(nil) - else - if cv ~= i then - set(input_nodes[v], i) + if not ok then + close_scope() -- subowner scope + error(result, 0) end + + input_nodes[v] = node + output_cache[v] = result + else -- update source + if cv ~= i then + input_nodes[v].cache = i + update(input_nodes[v]) + end + cur_input_cache[v] = nil end end + close_scope() + -- remove old values for v in next, cur_input_cache do - for _, callback in next, cleanups[v] do - callback() -- todo: pcall - end + destroy(scopes[v]) output_cache[v] = nil input_nodes[v] = nil - cleanups[v] = nil + scopes[v] = nil end -- update buffer cache table.clear(cur_input_cache) cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache - -- output elements - table.clear(output_array) - + local output_array = table.create(#scopes) for _, v in next, output_cache do table.insert(output_array, v) end + check_primitives(output_array) return output_array end - local output, read_output_value = create(nil :: any) - - local function derive() - return recompute(input()) - end - - local nodes, value = capture(input) - - for _, node in next, nodes do - link(node, output, derive) - end - check_primitives(output_array) - - output.cache = recompute(value) - - cleanup_ref(tostring(output), output, function() - for _, callbacks in next, cleanups do - for _, callback in next, callbacks do - callback() -- todo: pcall - end - end + local node = create_node(false :: any, function() + return update_children(input()) end) - return read_output_value + evaluate_node(node) + + return function() + track(node) + return node.cache + end end return function() return indexes, values end diff --git a/src/mount.luau b/src/mount.luau new file mode 100644 index 0000000..315925e --- /dev/null +++ b/src/mount.luau @@ -0,0 +1,14 @@ +if not game then script = require "test/relative-string" end + +local root = require(script.Parent.root) +local apply = require(script.Parent.apply) + +local function mount(component: () -> T, target: Instance?): () -> () + return root(function(destroy) + local result = component() + if target then apply(target, { result }) end + return destroy + end) +end + +return mount :: ((component: () -> T, target: Instance) -> () -> ()) & ((component: () -> ()) -> () -> ()) diff --git a/src/root.luau b/src/root.luau new file mode 100644 index 0000000..a2900cd --- /dev/null +++ b/src/root.luau @@ -0,0 +1,38 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_node = graph.create_node +local open_scope = graph.open_scope +local close_scope = graph.close_scope +local destroy = graph.destroy + +local refs = {} + +local function root(fn: (destroy: () -> ()) -> T...): T... + local node = create_node(false, false) + + refs[node] = true -- prevent gc of root node + + local destroy = function() + if not refs[node] then throw "root already destroyed" end + refs[node] = nil + destroy(node) + end + + open_scope(node) + + local result = { pcall(fn, destroy) } + + close_scope() + + if not result[1] then + refs[node] = nil + throw(`mount error\n{result}`) + end + + return unpack(result :: any, 2) +end + +return root :: ((fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ()) diff --git a/src/source.luau b/src/source.luau index d0da051..f1307fc 100644 --- a/src/source.luau +++ b/src/source.luau @@ -2,23 +2,30 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) type Node = graph.Node -local create = graph.create -local set = graph.set +local create_start_node = graph.create_start_node +local track = graph.track +local update = graph.update export type Source = (() -> T) & ((T) -> T) -local function source(value: T): Source - local node, read_node_value = create(value :: T) +local function source(initial_value: T): Source + local node = create_start_node(initial_value) return function(...): T - if select("#", ...) == 0 then return read_node_value() end -- check if any args were given + if select("#", ...) == 0 then -- no args were given + track(node) + return node.cache + end local v = ... :: T - if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then return v end + if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then + return v + end - set(node, v) + node.cache = v + update(node) return v end end -return source :: ((value: T) -> Source) & (() -> Source) +return source :: ((initial_value: T) -> Source) & (() -> Source) diff --git a/src/spring.luau b/src/spring.luau index 1f8fdb3..9503e15 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -24,10 +24,14 @@ Unsupported datatypes: local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node -local create = graph.create -local set = graph.set -local set_effect = graph.set_effect -local capture = graph.capture +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 evaluate_node = graph.evaluate_node +local update = graph.update +local set_owner = graph.set_owner +local track = graph.track local UPDATE_RATE = 120 local TOLERANCE = 0.0001 @@ -38,6 +42,8 @@ local function Vec3(x: number?, y: number?, z: number?) return Vector3.new(x, y, z) end +local ZERO = Vec3(0, 0, 0) + type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 type SpringData = { @@ -62,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) @@ -84,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) @@ -141,19 +146,16 @@ setmetatable(vec6_to_type, invalid_type) -- maps spring data to its corresponding output node -- lifetime of spring data is tied to output node -local springs: { [SpringData]: Node } = {} +local springs: { [SpringData]: StartNode } = {} setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local inputs, initial_value = capture(source) - local output, output_get = create(initial_value) - - local vtype = typeof(initial_value) - - local x1_123, x1_456 = type_to_vec6[vtype](initial_value) + local owner = get_scope() + if not owner then + throw("cannot derive in non-reactive scope") + end; assert(owner) -- https://en.wikipedia.org/wiki/Damping - -- todo: calculate damped freq at 10tau instead of natural freq local w_n = 2*math.pi / (period or 1) local z = damping_ratio or 1 @@ -166,34 +168,42 @@ local function spring(source: () -> T, period: number?, damping_ratio: number k = k, c = c, - x0_123 = x1_123, - x1_123 = x1_123, - v_123 = Vec3(), + x0_123 = ZERO, + x1_123 = ZERO, + v_123 = ZERO, - x0_456 = x1_456, - x1_456 = x1_456, - v_456 = Vec3(), + x0_456 = ZERO, + x1_456 = ZERO, + v_456 = ZERO, - source_value = initial_value, + source_value = false :: any, } + + local output = create_start_node(false :: any) - -- reschedule spring for simulation on input update - local function input_updated(node) - local v = source() - data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v) - data.source_value = v - springs[data] = node -- todo: investigate why insertion is not O(1) at ~20k springs + local function updater_effect() + local value = source() + data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) + data.source_value = value + springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs + return value end - -- unused field, use so output prevents gc of inputs - output.derive = source :: any + local updater = create_node(false :: any, updater_effect) - -- register above function as side-effect for all inputs - for _, input in next, inputs do - set_effect(input, input_updated, output) + set_owner(updater, owner) + evaluate_node(updater) + + -- set initial position to goal + data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + + -- set output to goal + output.cache = data.source_value + + return function() + track(output) + return output.cache end - - return output_get, data end local function step_springs(dt: number) @@ -251,10 +261,12 @@ local function update_spring_sources() if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then -- close enough to target, unshedule spring and set value to target table.insert(remove_queue, data) - set(output, data.source_value) + output.cache = data.source_value else - set(output, vec6_to_type[typeof(data.source_value)](x0_123, x0_456)) + output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) end + + update(output) end for _, data in next, remove_queue do diff --git a/src/switch.luau b/src/switch.luau new file mode 100644 index 0000000..31c2b4a --- /dev/null +++ b/src/switch.luau @@ -0,0 +1,71 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +type StartNode = graph.StartNode +local create_node = graph.create_node +local evaluate_node = graph.evaluate_node +local set_owner = graph.set_owner +local track = graph.track +local destroy = graph.destroy +local get_scope = graph.get_scope +local open_scope = graph.open_scope +local close_scope = graph.close_scope + +type Map = { [K]: V } + +local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? + return function(map) + local owner = get_scope() + if not owner then + throw("cannot switch in non-reactive scope") + end; assert(owner) + + local last_scope: Node? + local last_component: (() -> U)? + + local function update(cached): U? + local component = map[source()] + if component == last_component then return cached end + last_component = component + + if last_scope then + destroy(last_scope :: Node) + last_scope = nil + end + + if component == nil then return nil end + + if type(component) ~= "function" then + throw("map must map a value to a function") + end + + local new_scope = create_node(false, false) + last_scope = new_scope :: Node + + set_owner(new_scope, owner) + open_scope(new_scope) + + local ok, result = pcall(component) + + close_scope() + + if not ok then error(result, 0) end + + return result + end + + local node = create_node(nil :: any, update) + + set_owner(node, owner) + evaluate_node(node) + + return function() + track(node) + return node.cache + end + end +end + +return switch 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 ec1e774..230674d 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,20 +1,27 @@ if not game then script = require "test/relative-string" end +local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node -local refs = graph.refs +local get_scope = graph.get_scope local function untrack(source: () -> T): T - local initial = #refs + local scope = get_scope() + if not scope then + throw("cannot untrack in non-reactive scope") + end; assert(scope) - local value = source() + -- sources are only tracked if the node in scope has an effect + local effect = scope.effect + scope.effect = false - -- remove any references made since `untrack()` was called - for i = initial, #refs do - refs[i] = nil - end + local ok, result = pcall(source) - return value + scope.effect = effect :: () -> () + + if not ok then error(result, 0) end + + return result end return untrack diff --git a/src/watch.luau b/src/watch.luau deleted file mode 100644 index 27233ca..0000000 --- a/src/watch.luau +++ /dev/null @@ -1,28 +0,0 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) -local set_effect = graph.set_effect -local capture = graph.capture - -local function watch(effect: () -> ()): () -> () - local nodes = capture(effect :: () -> nil) - - -- store aside captured nodes in new table - nodes = table.clone(nodes) - - -- register effect with permanent lifetime - for _, node in next, nodes do - set_effect(node, effect, true) - end - - local function unwatch() - -- unregister effect from all nodes - for _, node in next, nodes do - set_effect(node, effect, nil) - end - end - - return unwatch -end - -return watch diff --git a/test/benchmark.luau b/test/benchmark.luau index 4718aae..6702235 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -1,167 +1,291 @@ -local BENCH, START = require("test/testkit").benchmark() - --- try prevent inlining by wrapping in a closure referencing an upvalue that --- cannot be determined at compile-time -local function NO_INLINE(fn) - local r = math.random() - return function(x) - local _ = r - fn(x) - end -end +local testkit = require("test/testkit") +local BENCH, START = testkit.benchmark() local vide = require "src/init" +local source = vide.source +local derive = vide.derive +local indexes = vide.indexes +local values = vide.values +local cleanup = vide.cleanup +local create = vide.create -local N = 2^18 -- 262144[ +local function TITLE(name: string) + print() + print(testkit.color.white(name)) +end -BENCH("create state", function() +local N = 2^18 -- 262144 + +local function WRAP_BENCH(name: string, fn: () -> ()) + vide.root(function(destroy) + BENCH(name, fn) + return destroy + end)() +end + +TITLE "sources" + +WRAP_BENCH("create source", function() local cache = table.create(N) - local source = vide.source for i = 1, START(N) do cache[i] = source(1) end end) -BENCH("get value", function() - local state = vide.source(1) +WRAP_BENCH("get value", function() + local src = source(1) for i = 1, START(N) do - state() + src() end end) -BENCH("set value", function() - local state = vide.source(1) +WRAP_BENCH("set value", function() + local src = source(1) for i = 1, START(N) do - state(i) + src(i) end end) -BENCH("derive 1 state", function() +WRAP_BENCH("derive 1 source", function() local cache = table.create(N) - local state = vide.source(1) - local derive = vide.derive + local src = source(1) for i = 1, START(N) do cache[i] = derive(function() - return state() + return src() end) end end) -BENCH("derive 4 states", function() +WRAP_BENCH("derive 4 sources", function() local cache = table.create(N) - local state = vide.source(1) - local state2 = vide.source(2) - local state3 = vide.source(3) - local state4 = vide.source(4) - local derive = vide.derive + local src = vide.source(1) + local src2 = vide.source(2) + local src3 = vide.source(3) + local src4 = vide.source(4) for i = 1, START(N) do cache[i] = derive(function() - return state() + state2() + state3() + state4() + return src() + src2() + src3() + src4() end) end end) -BENCH("set derived value", function() - local state = vide.source(1) - local _derived = vide.derive(state) +TITLE "graphs" + +WRAP_BENCH("update 1->1 graph", function() + local src = source(1) + + local _derived = derive(function() return src() end) for i = 1, START(N) do - state(i) + src(i) end end) -BENCH("apply 0 properties", function() +WRAP_BENCH("update 1->1 graph with cleanup", function() + local src = source(1) + + derive(function() + cleanup(function() end) + return src() + end) + + for i = 1, START(N) do + src(i) + end +end) + +WRAP_BENCH("update 1->1000 graph", function() + local src = source(-1) + + for i = 1, 1000 do + derive(function() return src() end) + end + + src(0) + + for i = 1, START(10) do + src(i) + end +end) + +WRAP_BENCH("update 1->1->1->1...1000 graph", function() + local src = source(-1) + + local last = src + for i = 1, 1000 do + local l = last + last = derive(function() return l() end) + end + + src(0) + + for i = 1, START(10) do + src(i) + end +end) + +-- todo: repeat with batching +WRAP_BENCH("update 1000->1 graph", function() + local srcs = {} + for i = 1, 1000 do + srcs[i] = source(0) + end + + derive(function() + for i = 1, 1000 do + srcs[i]() + end + return false + end) + + for i = 1, START(1) do + for idx = 1, 1000 do + srcs[idx](i) + end + end +end) + +-- todo: optimize, repeat with batching +WRAP_BENCH("update 1000x 1->1 common extern. graph", function() + local ext = source(-1) + + local srcs = {} + for i = 1, 1000 do + srcs[i] = source(0) + derive(function() return srcs[i]() + ext() end) + end + + ext(0) + + for i = 1, START(10) do + for idx = 1, 1000 do + srcs[idx](i) + end + end +end) + +TITLE "property apply" + +WRAP_BENCH("apply 0 properties", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} + local instance = create("Frame") {} for i = 1, START(N) do apply(instance, {}) end end) -BENCH("apply 8 properties", function() +WRAP_BENCH("apply 8 properties", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} + local instance = create("Frame") {} for i = 1, START(N) do apply(instance, { - Name = i, - Name2 = i, - Name3 = i, - Name4 = i, - Name5 = i, - Name6 = i, - Name7 = i, - Name8 = i, + Text = i, + Text2 = i, + Text3 = i, + Text4 = i, + Text5 = i, + Text6 = i, + Text7 = i, + Text8 = i, }) end end) -BENCH("bind source", function() +WRAP_BENCH("bind property", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} - local state = vide.source(1) + + local instance = create("Frame") {} + local src = source(1) for i = 1, START(N) do apply(instance, { - Name = state + Text = src }) end + + return nil end) -BENCH("update binding", function() +WRAP_BENCH("update binding", function() local apply = require "src/apply" - local instance = vide.create("Frame") {} - local state = vide.source(1) + + local instance = create("Frame") {} + local src = source(1) apply(instance, { - Name = state + Text = src }) for i = 1, START(N) do - state(i) + src(i) end + + return nil end) -BENCH("indexes() no change", function() +TITLE "indexes()" + +N /= 1024 + +WRAP_BENCH("indexes() all new", function() local data = {} for i = 1, N do data[i] = i end - local state = vide.source(data) - - local _list = vide.indexes(state, function(v, i) - return {} - end) - - --state(state()) -- fill double buffer + local src = source(data) START(N) - state(data) + local _list = indexes(src, function(v, i) + return {} + end) + + return nil end) -BENCH("indexes() all change", function() +WRAP_BENCH("indexes() no change", function() local data = {} for i = 1, N do data[i] = i end - local state = vide.source(data) + local src = source(data) - local _list = vide.indexes(state, function(v, i) + local _list = indexes(src, function(v, i) return {} end) - --state(state()) -- fill double buffer + START(N) + + src(data) + + return nil +end) + +WRAP_BENCH("indexes() all change", function() + local data = {} + + for i = 1, N do + data[i] = i + end + + local src = source(data) + + local _list = indexes(src, function(v, i) + return {} + end) + + --src(src()) -- fill double buffer for i, v in data do data[i] = v + 1 @@ -169,19 +293,19 @@ BENCH("indexes() all change", function() START(N) - state(data) + src(data) end) -BENCH("indexes() all remove", function() +WRAP_BENCH("indexes() all remove", function() local data = {} for i = 1, N do data[i] = i end - local state = vide.source(data) + local src = source(data) - local _list = vide.indexes(state, function(v, i) + local _list = indexes(src, function(v, i) return {} end) @@ -189,43 +313,65 @@ BENCH("indexes() all remove", function() START(N) - state(data) + src(data) + + return nil end) -BENCH("values() no change", function() +TITLE "values()" + +WRAP_BENCH("values() all new", function() local data = {} for i = 1, N do data[i] = {} end - local state = vide.source(data) + local src = source(data) - local _list = vide.values(state, function(v, i) + START(N) + + local _list = values(src, function(v, i) return {} end) - state(state()) -- fill double buffer + return nil +end) + +WRAP_BENCH("values() no change", function() + local data = {} + + for i = 1, N do + data[i] = {} + end + + local src = source(data) + + local _list = values(src, function(v, i) + return {} + end) + + src(src()) -- fill double buffer START(N) - state(data) + src(data) end) -BENCH("values() all change", function() +WRAP_BENCH("values() all change", function() local data = {} for i = 1, N do data[i] = {} end - local state = vide.source(data) + local src = source(data) - local _list = vide.values(state, function(v, i) + local _list = values(src, function(v, i) return {} end) - state(state()) -- fill double buffer + src(src()) -- fill double buffer for i = 1, N do local r = math.random(1, #data) @@ -234,19 +380,19 @@ BENCH("values() all change", function() START(N) - state(data) + src(data) end) -BENCH("values() all remove", function() +WRAP_BENCH("values() all remove", function() local data = {} for i = 1, N do data[i] = {} end - local state = vide.source(data) + local src = source(data) - local _list = vide.values(state, function(v, i) + local _list = values(src, function(v, i) return {} end) @@ -254,11 +400,15 @@ BENCH("values() all remove", function() START(N) - state(data) + src(data) end) -BENCH("register new cleanup", function() - local cleanup = vide.cleanup +N *= 1024 + +TITLE "cleanup" + +WRAP_BENCH("register new cleanup", function() + local cleanup = cleanup local cleaner = function() end @@ -276,61 +426,12 @@ BENCH("register new cleanup", function() end end) --- cleanup cleanups from previous benchmark -;(collectgarbage :: any)("collect") -vide.step(0) - -BENCH("repeat cleanup", function() - local cleanup = vide.cleanup - - local foo = NO_INLINE(function(i) - cleanup(function() - -- return i -- uncomment to include overhead of closure creation - end) - end) - - for i = 1, START(N) do - foo(i) - end -end) - --- cleanup cleanups from previous benchmark -;(collectgarbage :: any)("collect") -vide.step(0) - -BENCH("cleanup gc check", function() - local cleanup = vide.cleanup - - local cleaner = function() end - - local callers = table.create(N) - - for i = 1, N do - callers[i] = function() - cleanup(cleaner) - return i - end - - callers[i]() - end - - START(N) - - vide.step(0) -end) - -BENCH("cleanup gc removal", function() - START(N) - - vide.step(0) -- cleanup from previous benchmark -end) - +TITLE "aggregate" do -- the purpose of the two following benchmarks is to measure the overhead of -- aggregate construction - BENCH("set explicit mock vector2", function() - local create = vide.create + WRAP_BENCH("set explicit mock vector2", function() local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -345,8 +446,7 @@ do end end) - BENCH("set aggregate mock vector2", function() - local create = vide.create + WRAP_BENCH("set aggregate mock vector2", function() local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -362,4 +462,47 @@ do end) end +-- innacurate due to no Vector3 in vanilla Luau +-- mock vector is 200x slower than native vector + +-- WRAP_BENCH("spring update", function() +-- local root, source, spring = vide.root, vide.source, vide.spring + +-- local src = source(0) + +-- root(function() +-- for i = 1, N do +-- spring(src) +-- end + +-- START(N) + +-- src(1) + +-- return nil +-- end) +-- end) + +-- N /= 1024 + +-- WRAP_BENCH("spring step", function() +-- local root, source, spring = vide.root, vide.source, vide.spring + +-- local src = source(0) + +-- root(function() +-- for i = 1, N do +-- spring(src) +-- end + +-- src(1) + +-- START(N) + +-- vide.step(1/60) + +-- return nil +-- end) +-- end) + return nil diff --git a/test/mock.luau b/test/mock.luau index 0c76dcb..1dfb9bc 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -113,6 +113,7 @@ local Instance = {} :: any do local function __newindex(userdata: userdata, property: string, value: unknown) local data = get_data(userdata) if property == "Name" then + if type(value) ~= "string" then error("name must be a string", 2) end data.name = value :: string elseif property == "Parent" then assert(value == nil or is_instance(value), "attempt to set non-instance as parent") 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 f2f19f3..c22d955 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -37,12 +37,12 @@ local function main() local source = vide.source local spring = vide.spring - local watch = vide.watch + local effect = vide.effect local value = source(MAX) local sprung = spring(value, 1, 0.3) - watch(function() + effect(function() local v = sprung() local fv = math.floor(v) local reset = "\27[H\27[2J" -- ANSI clear terminal @@ -65,7 +65,7 @@ local function main() until false end -main() +vide.root(main) diff --git a/test/tests.luau b/test/tests.luau index da7a3ec..4d8a52c 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -6,6 +6,10 @@ local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 local vide = require "src/init" +local graph = require "src/graph" +type Node = graph.Node + +type Map = { [K] : V } local function gc(n: number?) for i = 1, n or 3 do @@ -18,615 +22,603 @@ local function weak(t: T & {}): T return t end +local function wrap_root(fn: () -> ()) + return function() + local destroy = vide.mount(fn :: any) + destroy() + end +end + +local NIL = nil :: any + TEST("graph", function() - local graph = require "src/graph" - local create = graph.create - local get = graph.get - local set = graph.set - local capture = graph.capture - local capture_and_link = graph.capture_and_link - local link = graph.link - local set_effect = graph.set_effect + local create_node = graph.create_node + local track = graph.track + local update = graph.update + local add_child = graph.add_child + local get_scope = graph.get_scope + local open_scope = graph.open_scope + local close_scope = graph.close_scope + local get_children = graph.get_children + local add_cleanup = graph.add_cleanup + local destroy = graph.destroy - do CASE "node creation" - local node = create(1) - CHECK(get(node) == 1) + local function node(v: T?) + return create_node(v or false, function(x) return not x end) end - do CASE "node value" - local node = create(0) - set(node, 1) - CHECK(get(node) == 1) - set(node, 2) - CHECK(get(node) == 2) + local function scope() + return create_node(false, false) end - do CASE "capture nodes" - local node1 = create(nil) - local node2 = create(nil) - local nodes = capture(function() - return get(node1), get(node2) - end) - CHECK(nodes[1] == node1) - CHECK(nodes[2] == node2) + local function cleanup(fn: () -> ()) + local node = assert(get_scope()) + add_cleanup(node, fn) end - do CASE "linking nodes" - local parent = create(1) - local child = create(0) + do CASE "link nodes" + local a = node() + local b = node() + local c = node() - link(parent, child, function() - return get(parent) - end) + open_scope(c) - set(parent, get(parent) + 1) - CHECK(get(child) == 2) -- child should automatically update + track(a) + track(b) + + close_scope() + + CHECK(get_children(a)[1] == c) + CHECK(get_children(b)[1] == c) end - do CASE "capture and link nodes" - local parent = create(1) - local child = create() + do CASE "rerun linked nodes" + local a = node() + local b = node() + local c = node() - child.cache = capture_and_link(child, function() - return tostring(get(parent)) - end) + local count = 0 - set(parent, 2) - CHECK(get(child) == "2") + local function effect(x) + track(a) + track(b) + count += 1 + return not x + end + + c.effect = effect + + open_scope(c) + + effect(c.cache) + + close_scope() + + CHECK(count == 1) + update(a) + CHECK(count == 2) + update(b) + CHECK(count == 3) + end + + do CASE "diamond graph" + local a, b, c, d = node(), node(), node(), node() + + 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 + 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() + open_scope(d); track(b); track(c); close_scope() + + update(a) + + CHECK(b_cnt == 1) + CHECK(c_cnt == 1) + CHECK(d_cnt == 1) + end + + do CASE "duplicate child on rerun" + local a, b, c = node(), node(), node() + + function c.effect(x) + track(a) + track(b) + return not x + end + + open_scope(c); assert(c.effect)(NIL); close_scope() + + update(a) + + CHECK(#get_children(a) == 1) + CHECK(#get_children(b) == 1) + end + + do CASE "case 1" + -- construct graph + + local items = node { "a", "b" } + local selected = node "a" + + local root = scope() + + local scope1 = scope() + local scope2 = scope() + + local items_updated + + local bind1 + local bind2 + + local cleaned = {} :: { [any]: any } + + local function clean(s) + cleanup(function() + cleaned[s] = true + end) + end + + do open_scope(root) + clean "root" + items_updated = node() + track(items_updated) -- should not + + add_child(root, items_updated) + do open_scope(items_updated) + track(items) + + do open_scope(root) + add_child(root, scope1) + do open_scope(scope1) + clean "scope1" + bind1 = node() + + add_child(scope1, bind1) + do open_scope(bind1) + clean "bind1" + track(selected) + close_scope() end + close_scope() end + + add_child(root, scope2) + do open_scope(scope2) + clean "scope2" + bind2 = node() + add_child(scope2, bind2) + do open_scope(bind2) + clean "bind2" + track(selected) + close_scope() end + close_scope() end + close_scope() end + close_scope() end + close_scope() end + + + -- verify graph + + do + local c = get_children(items_updated) + CHECK(#c == 0) + end + + 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)) + end + + do + local c = get_children(selected) + CHECK(#c == 2) + CHECK(table.find(c, bind1)) + CHECK(table.find(c, bind2)) + end + + do + local c = get_children(scope1) + CHECK(#c == 1) + CHECK(table.find(c, bind1)) + end + + do + local c = get_children(scope2) + CHECK(#c == 1) + CHECK(table.find(c, bind2)) + end + + -- destroy + + CHECK(table.find(get_children(root), scope1 :: Node)) + + destroy(scope1) + CHECK(cleaned.scope1) + CHECK(cleaned.bind1) + scope1 = NIL + bind1 = NIL + bind2 = NIL + gc() + CHECK(#get_children(root) == 2) + CHECK(#get_children(selected) == 1) end do CASE "nodes garbage collection" - local wref = weak { create(1) } + local wref = weak { node(1) } + destroy(wref[1]) gc() CHECK(not wref[1]) end - - do CASE "node effect garbage collection" - do - local wref - - do - local function factory(p) -- factory function to prevent closure caching - return function() - return get(p) - end - end - - local node = create(1) - - do - local effect1 = factory(node) - local effect2 = factory(node) - - wref = weak { e1 = effect1, e2 = effect2, n = node} - - set_effect(node, effect1, {}) - set_effect(node, effect2, true) - end - - gc() - CHECK(not wref.e1) -- effect1 should gc since nothing is referencing table `t` - CHECK(wref.e2) -- effect2 should not gc as `true` is not garbage collectable - end - - gc() - CHECK(not wref.n and not wref.e2) -- node should now gc along with effect2 - end - - do - local wref - - do -- same test but for multiple nodes referenced by watcher - local function factory(a, b) - return (function(c, d) - return function() - return get(c), get(d) - end - end)(a, b) - end - - local node1 = create(1) - local node2 = create(1) - - do - local effect = factory(node1, node2) - - wref = weak { n1 = node1, n2 = node2, e = effect } - - local t1 = {} - set_effect(node1, effect, t1) - set_effect(node2, effect, t1) - end - - gc() - CHECK(not wref.e) - end - - gc() - CHECK(not wref.n1) - CHECK(not wref.n2) - end - end end) -TEST("source()", function() +TEST("mount()", function() + local mount = vide.mount + local create = vide.create local source = vide.source - local watch = vide.watch + 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("root()", function() + local root = vide.root + local cleanup = vide.cleanup + + local count = 0 + + root(function(destroy) + cleanup(function() count += 1 end) + destroy() + end) + + CHECK(count == 1) +end) + +TEST("source()", wrap_root(function() + local source = vide.source + local effect = vide.effect do CASE "create source" - local state = source(1) - CHECK(state() == 1) + local src = source(1) + CHECK(src() == 1) end do CASE "set and get source value" - local state = source(1) - state(2) - CHECK(state() == 2) + local src = source(1) + src(2) + CHECK(src() == 2) end do CASE "does not update if same value" - local state = source(1) + local src = source(1) - local updates = -1 - watch(function() - state() - updates += 1 + local count = 0 + effect(function() + src() + count += 1 end) - CHECK(updates == 0) - state(1) - CHECK(updates == 0) - state(2) - CHECK(updates == 1) + CHECK(count == 1) + src(1) + CHECK(count == 1) + src(2) + CHECK(count == 2) end - do CASE "does update if same value is table" - local state = source {} + do CASE "does update if same value is mutable table" + local src = source {} - local updates = -1 - watch(function() - state() - updates += 1 + local count = 0 + effect(function() + src() + count += 1 end) - CHECK(updates == 0) - state(state()) - CHECK(updates == 1) + CHECK(count == 1) + src(src()) + CHECK(count == 2) end do CASE "does not update if same value is frozen table" local a = table.freeze {} local b = table.freeze {} - local state = source(a) + local src = source(a) - local updates = -1 - watch(function() - state() - updates += 1 + local count = 0 + effect(function() + src() + count += 1 end) - CHECK(updates == 0) - state(a) - CHECK(updates == 0) - state(b) - CHECK(updates == 1) - state(b) - CHECK(updates == 1) + CHECK(count == 1) + src(a) + CHECK(count == 1) + src(b) + CHECK(count == 2) + src(b) + CHECK(count == 2) end +end)) - do CASE "garbage collection of node" - local capture = require "src/graph".capture - local src = source(0) - - local wref do - local node = unpack(capture(src)) - wref = weak { node } - end - - gc() - CHECK(not wref[1]) - end -end) - -TEST("derive()", function() +TEST("derive()", wrap_root(function() local source = vide.source local derive = vide.derive + local effect = vide.effect + local cleanup = vide.cleanup do CASE "derive new value on source change" - local inputA = source(1) - local inputB = source(2) + local a = source(1) + local b = source(2) - local output = derive(function() - return tostring(inputA() + inputB()) + local c = derive(function() + return tostring(a() + b()) end) - CHECK(output() == "3") - inputA(2) - CHECK(output() == "4") + CHECK(c() == "3") + a(2) + CHECK(c() == "4") end do CASE "derive wrapped source" - local input = source(1) + local a = source(1) - local transform = function() - return tostring(input()) + local b = function() + return tostring(a()) end - local output = derive(function() - return tonumber(transform()) + local c = derive(function() + return tonumber(b()) end) - CHECK(output() == 1) - input(2) - CHECK(output() == 2) + CHECK(c() == 1) + a(2) + CHECK(c() == 2) + end + + do CASE "does not update if same value" + local num = source(0) + + local is_even = derive(function() + return bit32.band(num(), 0b01) == 0 + end) + + local count = 0 + + effect(function() + is_even() + count += 1 + end) + + num(1) -- odd + CHECK(count == 2) + num(2) -- even + CHECK(count == 3) + num(4) -- even + CHECK(count == 3) + num(5) -- odd + CHECK(count == 4) + end + + do CASE "conditional derive" + local a = source(false) + local b = source(false) + + local c = derive(function() + return + if a() then "a" + elseif b() then "b" + else "never" + end) + + local count = 0 + + effect(function() c(); count += 1 end) + + b(true) + CHECK(c() == "b") + CHECK(count == 2) + a(true) + CHECK(c() == "a") + CHECK(count == 3) + b(false) + CHECK(count == 3) + b(true) + CHECK(count == 3) + a(false) + CHECK(c() == "b") + CHECK(count == 4) + end + + do CASE "owner not disconnected" + local count = 0 + local a = source(0) + + local destroy = vide.mount(function() + + local _b = derive(function() + cleanup(function() + count += 1 + end) + + return a() + end) + end) + + CHECK(count == 0) + a(1) -- b clears parents (should not clear owner) + CHECK(count == 1) + destroy() + CHECK(count == 2) end do CASE "garbage collection" - do -- check that `b` does not allow gc of `a` - local wref, b - - do - local a = source(1) - - b = derive(function() - return a() - end) + -- check that `b` does not allow gc of `a` + local a = source(1) - wref = weak { a } - end + local _b = derive(function() + return a() + end) - gc() - CHECK(wref[1]) - b() - end + _b = NIL - do -- check that `a` allows gc of `b` - local a = source(1) - - local wref - - do - local b = derive(function() - return a() - end) - - wref = weak { b } - end - - gc() - CHECK(not wref[1]) - end - end - - do CASE "garbage collection 2" - -- creats a chain `a -> b -> c` where `a` is the source - local function setup() - local a = source(0) - - local b = derive(function() - return a() - end) - - local c = derive(function() - return b() - end) - - return weak { a, b, c }, a, b, c - end - - do -- check that `b` and `c` can gc if `a` is referenced - local wref, _a = setup() - - gc() - CHECK(not wref[2]) - CHECK(not wref[3]) - end - - do -- check that `a` and `b` wont gc if `c` is referenced - local weak, _a, _b, _c = setup() - - _a, _b = nil :: any, nil :: any - - gc() - CHECK(weak[1]) - CHECK(weak[2]) - end - - do -- check that `b` wont gc if `a` and `c` are referenced - local weak, a, _b, c = setup() - - _b = nil :: any - - gc() - CHECK(weak[2]) - - a(2) - CHECK(c() == 2) - end - end - - do CASE "garbage collection of node" - local capture = require "src/graph".capture - local input = source(1) - - local wref do - local output = derive(input) - local output_node = unpack(capture(output)) - wref = weak { output_node } - end + local wref = weak { a } gc() - CHECK(not wref[1]) + CHECK(wref[1]) end -end) +end)) -TEST("watch()", function() +TEST("effect()", wrap_root(function() local source = vide.source - local watch = vide.watch - local cleanup = vide.cleanup + local effect = vide.effect + local derive = vide.derive - do CASE "capture sourcess" + do CASE "rerun on source change" local a = source(1) local b = source(1) - local runcount = -1 - watch(function() + local count = 0 + effect(function() a() b() - runcount += 1 + count += 1 end) - CHECK(runcount == 0) + CHECK(count == 1) a(2) - CHECK(runcount == 1) + CHECK(count == 2) b(2) - CHECK(runcount == 2) + CHECK(count == 3) end - do CASE "stop watch" - local a = source(1) + do CASE "rerun on derived source change" + local num = source(0) - local runcount = -1 - local unwatch = watch(function() - a() - runcount += 1 - end) + local text = derive(function() return tostring(num()) end) - unwatch() - a(2) - CHECK(runcount == 0) - end - - do CASE "side-effect cleanup" - local state = source(1) - - local effect_runcount = 0 - local cleanup_runcount = 0 - - local unwatch = watch(function() - state() - effect_runcount += 1 - cleanup(function() cleanup_runcount += 1 end) + local count = 0 + effect(function() + text() + count += 1 end) - CHECK(effect_runcount == 1) - CHECK(cleanup_runcount == 0) - state(2) - CHECK(effect_runcount == 2) - CHECK(cleanup_runcount == 1) + num(1) - unwatch() - unwatch = nil :: any - gc() - vide.step(0) - - CHECK(effect_runcount == 2) - CHECK(cleanup_runcount == 2) + CHECK(count == 2) end - do CASE "garbage collection" - local function factory(p) - return function() - p() - end - end + do CASE "cache" + local num = source(0) - do -- state prevents gc of watcher - local state = source(1) + local count - local wref + effect(function(x: number) + num() + count = x + 1 + return x + 1 + end, 0) - do - local effect = factory(state) - watch(effect) - wref = { effect } - end + num(1) - gc() - CHECK(wref[1]) - end - - do -- watcher can gc if stopped - local state = source(1) - - local wref, unwatch - - do - local effect = factory(state) - unwatch = watch(effect) - wref = weak { effect } - end - - gc() - CHECK(wref[1]) - - unwatch() - unwatch = nil :: any -- unwatch holds ref to effect - - gc() - CHECK(not wref[1]) - - end - - do -- state can gc with watcher - local wref - - do - local state = source(1) - local effect = factory(state) - watch(effect) - wref = weak { state } - end - - gc() - CHECK(not wref[1]) - end + CHECK(count == 2) end -end) +end)) -TEST("cleanup()", function() +TEST("cleanup()", wrap_root(function() local source = vide.source - local watch = vide.watch + local effect = vide.effect local cleanup = vide.cleanup - do CASE "cleanup runs for watcher" - local state = source(1) + do CASE "root cleanup" + local count = 0 - local watched = 0 + local destroy = vide.mount(function() + cleanup(function() + count += 1 + end) + end) + + CHECK(count == 0) + destroy() + CHECK(count == 1) + end + + do CASE "cleanup on rerun" + local src = source(1) + + local effected = 0 local cleaned = 0 - local stop = watch(function() - state() - watched += 1 + effect(function() + src() + effected += 1 cleanup(function() cleaned += 1 end) end) - CHECK(watched == 1) + CHECK(effected == 1) CHECK(cleaned == 0) - state(2) + src(2) - CHECK(watched == 2) + CHECK(effected == 2) CHECK(cleaned == 1) - - stop() - - do -- vide detects by iterating through and checking for gc'd refs - stop = nil :: any - gc() - vide.step(0) - end - - CHECK(watched == 2) - CHECK(cleaned == 2) end - do CASE "scoped" - local function setup() - local state = source(1) - local obj = { cleaned = 0 } - - local _stop = watch(function() - state() - cleanup(function() - obj.cleaned += 1 - end) - end) - - return state, obj - end - - local stateA, objA = setup() - local stateB, objB = setup() - - CHECK(objA.cleaned == 0) - CHECK(objB.cleaned == 0) - - stateA(2) - - CHECK(objA.cleaned == 1) - CHECK(objB.cleaned == 0) - - stateB(2) - - CHECK(objA.cleaned == 1) - CHECK(objB.cleaned == 1) - - do - stateA = nil :: any - gc() - vide.step(0) - end - - CHECK(objA.cleaned == 2) - CHECK(objB.cleaned == 1) - - do - stateB = nil :: any - gc() - vide.step(0) - end - - CHECK(objA.cleaned == 2) - CHECK(objB.cleaned == 2) - end - - -- this is not allowed, test to verify behavior anyways do CASE "multiple cleanup" - local state = source(1) + local src = source(1) local queue = {} - watch(function() - state() - cleanup(function() table.insert(queue, 1) end) - cleanup(function() table.insert(queue, 2) end) - end) - - CHECK(testkit.seq(queue, { 1 })) - state(2) - CHECK(testkit.seq(queue, { 1, 2, 1 })) - state(3) - CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1 })) - end - - --[[ - do CASE "multiple cleanup" - local state = source(1) - - local queue = {} - - watch(function() - state() + effect(function() + 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 })) - - do - state = nil :: any - gc() - vide.step(0) - end - - -- todo: guarantee call order when gc? (currently not) - --testkit.print2(queue) - --CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1, 2 })) end - ]] -end) +end)) -TEST("create()", function() +TEST("create()", wrap_root(function() local create = vide.create local source = vide.source + local cleanup = vide.cleanup do CASE "apply default properties" local defaults = require("src/defaults") @@ -673,9 +665,7 @@ TEST("create()", function() { Text = "1", - { - Text = "2" - } + { Text = "2" } } } @@ -709,7 +699,6 @@ TEST("create()", function() CHECK(frame:FindFirstChild "C") CHECK(frame:FindFirstChild "D") CHECK(frame:FindFirstChild "E") - CHECK(frame:FindFirstChild "F") CHECK(frame:FindFirstChild "G") end @@ -733,119 +722,38 @@ TEST("create()", function() CHECK(label.Text == "Bar") end - do CASE "binding garbage collection" - do -- instance should gc when unparented - local state = source("Hi") + do CASE "binding destroy" + local count = 0 - local wref = weak { - create "TextLabel" { - Text = state, - } + local destroy = vide.mount(function() + local src = source(0) + + return create "TextLabel" { + Text = function() + cleanup(function() + count += 1 + end) + + return src() + end } + end) - gc() - CHECK(not wref[1]) - end - - do -- instance should not gc when parented - local state = source("Hi") - - local parent = create "Frame" {} - - local wref = weak { - create "TextLabel" { - Parent = parent, - Text = state, - } - } - - gc() - CHECK(wref[1]) - - wref[1].Parent = nil - wref[1].Parent = parent - - gc() - CHECK(wref[1]) - - wref[1]:Destroy() - - gc() - CHECK(not wref[1]) - end - - do -- instance does not allow gc of state - local label - local wref - - do - local state = source("Hi") - label = create "TextLabel" { - Name = state, - } - wref = weak { state :: any, label } - - end - - gc() - CHECK(wref[2]) - CHECK(wref[1]) - end - - do -- state and instance should gc once both exit scope - local wref - - do - local text = source("Hi") - - local box = create "TextLabel" { - Text = text, - } - - wref = weak { text = text, box = box} - end - - gc() - CHECK(not wref.text) - CHECK(not wref.box) - end - - do -- binding should gc despite state still existing after instance is gc - local state = source("Hi") - - local node = require "src/graph".capture(state)[1] - - local wref - - do - local instance = create "TextLabel" { - Text = state, - } - - wref = weak { - instance = instance, - binding = next(node.effects) - } - end - - CHECK(wref.binding) - - gc() - CHECK(not wref.instance) - CHECK(not wref.binding) - end + CHECK(count == 0) + destroy() + CHECK(count == 1) end - do CASE "bind same state to multiple instance properties" - local state = source "1" + do CASE "bind same source to multiple instance properties" + local src = source "1" local text = create "TextBox" { - Name = state, - Text = state, - PlaceholderText = state + Name = src, + Text = src, + PlaceholderText = src } - state "2" + src "2" CHECK(text.Name == "2") CHECK(text.Text == "2") @@ -853,7 +761,7 @@ TEST("create()", function() end do CASE "bind children" - local state = source() + local children = source() local a, b, c = create "TextLabel" { Name = "A" }, @@ -861,50 +769,53 @@ TEST("create()", function() create "TextLabel" { Name = "C" } local frame = create "Frame" { - state + children } - state { a, b } + children { a, b } CHECK(frame:FindFirstChild "A") CHECK(frame:FindFirstChild "B") -- check that b is removed and c is added while a remains untouched - state { a, c } + children { a, c } CHECK(frame:FindFirstChild "A") CHECK(frame:FindFirstChild "C") CHECK(not frame:FindFirstChild "B") - state(nil) + children(nil) CHECK(#frame:GetChildren() == 0) end - --[[ - do CASE "Parent set to nil by state does not allow gc" - local frame = create "Frame" { Name = "Parent" } - local parent = source(frame :: Frame?) + do CASE "parent bound to source" + local wref, destroy = vide.root(function(destroy) + local frame = create "Frame" { Name = "Parent" } + local parent = source(frame :: Frame?) - local wref = weak { - create "TextLabel" { Parent = parent, Name = "Child" } - } + local wref = weak { + create "TextLabel" { Parent = parent, Name = "Child" } + } + + gc() + CHECK(wref[1]) + + parent(nil) + + return wref, destroy + end) gc() CHECK(wref[1]) - parent(nil) - - gc() - CHECK(wref[1]) - - wref[1]:Destroy() + destroy() + destroy = NIL gc() CHECK(not wref[1]) end - ]] do CASE "garbage collection test" local wref @@ -931,11 +842,119 @@ TEST("create()", function() gc() CHECK(wref.data and wref.proxy) end -end) +end)) --- todo: gc and cleanup call check for removed element +TEST("switch()", wrap_root(function() + local source = vide.source + local switch = vide.switch + local effect = vide.effect + local cleanup = vide.cleanup -TEST("indexes()", function() + do CASE "update on source change" + local input = source(true) + + local output = switch(input) { + [true] = function() return 1 end, + [false] = function() return 0 end + } + + local count = 0 + + effect(function() output(); count += 1 end) + + CHECK(count == 1) + CHECK(output() == 1) + + input(false) + CHECK(output() == 0) + CHECK(count == 2) + + input(false) + CHECK(output() == 0) + CHECK(count == 2) + + 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) + + local owner_count = 0 + local switch0_count = 0 + local switch1_count = 0 + + cleanup(function() owner_count += 1 end) + + local output = switch(input) { + [true] = function() + cleanup(function() switch1_count += 1 end) + return 1 + end, + + [false] = function() + cleanup(function() switch0_count += 1 end) + return 0 + end + } + + CHECK(output() == 1) + input(false) + CHECK(switch1_count == 1) + CHECK(switch0_count == 0) + input(true) + CHECK(switch1_count == 1) + CHECK(switch0_count == 1) + 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() local create = vide.create local source = vide.source local indexes = vide.indexes @@ -956,10 +975,10 @@ TEST("indexes()", 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) @@ -969,9 +988,9 @@ TEST("indexes()", 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" @@ -997,7 +1016,7 @@ TEST("indexes()", function() CHECK(t[1].Text == "1") CHECK(t[2].Text == "2") - CHECK(t[3] == nil :: any) + CHECK(t[3] == NIL) CHECK(destroyed == true) end @@ -1011,7 +1030,7 @@ TEST("indexes()", function() local wref = weak { input } - input = nil :: any + input = NIL gc() CHECK(wref[1]) @@ -1026,7 +1045,7 @@ TEST("indexes()", function() local wref = weak { output } - output = nil :: any + output = NIL gc() CHECK(not wref[1]) @@ -1037,21 +1056,8 @@ TEST("indexes()", function() local input = source { 1, 2, 3 } local count = table.create(3, 0) - local unrelated_count = 0 - - local unrelated = (function() - return function() - cleanup(function() - unrelated_count += 1 - end) - end - end)() local output = indexes(input, function(v, i) - -- check that overriden cleanup scopes don't affect cleanup calls - -- in other function scopes - unrelated() - cleanup(function() count[i] += 1 end) @@ -1064,20 +1070,31 @@ TEST("indexes()", function() CHECK(count[1] == 0) CHECK(count[2] == 0) CHECK(count[3] == 0) - CHECK(unrelated_count == 2) - - output = nil :: any - gc() - vide.step(0) - - CHECK(count[1] == 1) - CHECK(count[2] == 1) - CHECK(count[3] == 1) - CHECK(unrelated_count == 2) end -end) -TEST("values()", function() + 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() local create = vide.create local source = vide.source local values = vide.values @@ -1098,10 +1115,10 @@ TEST("values()", 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) @@ -1111,9 +1128,9 @@ TEST("values()", 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" @@ -1139,7 +1156,7 @@ TEST("values()", function() CHECK(t[1].Text == "1") CHECK(t[2].Text == "2") - CHECK(t[3] == nil :: any) + CHECK(t[3] == NIL) CHECK(destroyed == true) end @@ -1165,21 +1182,8 @@ TEST("values()", function() local input = source { 1, 2, 3 } local count = table.create(3, 0) - local unrelated_count = 0 - - local unrelated = (function() - return function() - cleanup(function() - unrelated_count += 1 - end) - end - end)() local output = values(input, function(v, i) - -- check that overriden cleanup scopes don't affect cleanup calls - -- in other function scopes - unrelated() - cleanup(function() count[i()] += 1 end) @@ -1192,75 +1196,87 @@ TEST("values()", function() CHECK(count[1] == 0) CHECK(count[2] == 0) CHECK(count[3] == 0) - CHECK(unrelated_count == 2) - - output = nil :: any - gc() - vide.step(0) - - CHECK(count[1] == 1) - CHECK(count[2] == 1) - CHECK(count[3] == 1) - CHECK(unrelated_count == 2) end -end) -TEST("spring()", function() + 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() local create = vide.create local source = vide.source local spring = vide.spring - local watch = vide.watch + local effect = vide.effect do CASE "update source (on next step)" local value = source(10) - local springed = spring(value, 1, 1) + local sprung = spring(value, 1, 1) + CHECK(sprung() == 10) value(20) - CHECK(springed() == 10) + CHECK(sprung() == 10) vide.step(1/60) - CHECK(springed() ~= 10) - CHECK(springed() > 10) + CHECK(sprung() ~= 10) + CHECK(sprung() > 10) end do CASE "garbage collection" + --[[ do -- `output` should not allow gc of `input` local input = source(10) local _output = spring(input) local wref = weak { input } - input = nil :: any + input = NIL gc() CHECK(wref[1]) end + ]] do -- `input` should allow gc of `output` local input = source(10) local output = spring(input) local wref = weak { output } - output = nil :: any + output = NIL gc() CHECK(not wref[1]) end - do -- spring data gc - local capture = require "src/graph".capture + -- do -- spring data gc + -- local input = source(10) - local input = source(10) - - local wref do - local output, data = (spring :: any)(input) - input(input() + 1) -- schedule spring calculation - local output_node = unpack(capture(output)) - wref = weak { output_node, data } - end + -- local wref do + -- local output, data = (spring :: any)(input) + -- input(input() + 1) -- schedule spring calculation + -- local output_node = unpack(capture(output)) + -- wref = weak { output_node, data } + -- end - gc() - CHECK(not wref[1]) - CHECK(not wref[2]) - end + -- gc() + -- CHECK(not wref[1]) + -- CHECK(not wref[2]) + -- end end do CASE "garbage collection (binded)" @@ -1272,7 +1288,7 @@ TEST("spring()", function() } local wref = { output } - output = nil :: any + output = NIL gc() CHECK(wref[1]) -- `output` should not gc @@ -1289,7 +1305,7 @@ TEST("spring()", function() CHECK(output() == input()) -- check spring is at target local count = -1 - watch(function() + effect(function() output() count += 1 end) @@ -1303,35 +1319,38 @@ TEST("spring()", function() vide.step(0) -- process spring queue CHECK(count == 1) -- check spring was rescheduled correctly end -end) +end)) -TEST("untrack()", function() +TEST("untrack()", wrap_root(function() + local root = vide.root local source = vide.source - local watch = vide.watch + local derive = vide.derive + local effect = vide.effect + local cleanup = vide.cleanup local untrack = vide.untrack do CASE "does not register dependency" local a = source(0) local b = source(0) - local count = -1 + local count = 0 - watch(function() + effect(function() count += 1 untrack(a) b() end) b(1) - CHECK(count == 1) + CHECK(count == 2) a(1) - CHECK(count == 1) + CHECK(count == 2) 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) @@ -1340,22 +1359,74 @@ TEST("untrack()", function() return a() + b() end - local count = -1 + local count = 0 - watch(function() + effect(function() count += 1 untrack(d) c() end) c(1) - CHECK(count == 1) + CHECK(count == 2) a(1) b(1) - CHECK(count == 1) + CHECK(count == 2) end -end) + + do CASE "outer scope" + local outer_count = 0 + local inner_count = 0 + local cleaned_count = 0 + + local input = source(0) + + local output, destroy = root(function(destroy) + local output = derive(function() + outer_count += 1 + + return untrack(function() + return derive(function() + inner_count += 1 + + cleanup(function() + cleaned_count += 1 + end) + + return tostring(input()) + end) + end) + end) + + return output, destroy + end) + + CHECK(outer_count == 1) + CHECK(inner_count == 1) + CHECK(cleaned_count == 0) + + local output2 = output() + + CHECK(output2() == "0") + + input(1) + + CHECK(outer_count == 1) + CHECK(inner_count == 2) + CHECK(cleaned_count == 1) + + local output3 = output() + CHECK(output2() == "1") + CHECK(output3() == "1") + + CHECK(output2 == output3) + + destroy() + + CHECK(cleaned_count == 2) + end +end)) TEST("events", function() local create = vide.create @@ -1421,36 +1492,74 @@ TEST("actions", function() end end) -TEST("strict", function() +TEST("changed()", wrap_root(function() + local root = vide.root + local create = vide.create + local source = vide.source + local changed = vide.changed + + do CASE "outputs" + local output = source(nil) + + local text = create "TextLabel" { + Text = "a", + changed("Text", output) + } + + --CHECK(output() == "a") + text.Text = "b" + CHECK(output() == "b") + end + + do CASE "connection disconnected" + local text, destroy = root(function(destroy) + local output = source(nil) + + return create "TextLabel" { + Text = "a", + changed("Text", output) + }, destroy + end) + + destroy() -- changed() should of disconnect connection + + -- check if instance can gc + local wref = weak { text } + text = NIL + gc() + CHECK(not wref[1]) + end +end)) + +TEST("strict", wrap_root(function() vide.strict = true local create = vide.create local source = vide.source local derive = vide.derive - local watch = vide.watch + local effect = vide.effect local indexes, values = vide.indexes, vide.values - local cleanup = vide.cleanup do CASE "error on derived callback yield" - local state = source(1) + local src = source(1) local ok = pcall(function() local _derived = derive(function() coroutine.yield() - return state() + return src() end) end) CHECK(not ok) end - do CASE "error on watcher callback yield" - local state = source(1) + do CASE "error on effecter callback yield" + local src = source(1) local ok = pcall(function() - local _derived = watch(function() + effect(function() coroutine.yield() - state() + src() end) end) @@ -1458,48 +1567,48 @@ TEST("strict", function() end do CASE "run derived callback twice" - local state = source(1) - local runcount = 0 + local src = source(1) + local count = 0 local _ = derive(function() - runcount += 1 - return state() + count += 1 + return src() end) - CHECK(runcount == 2) - state(2) - CHECK(runcount == 4) + CHECK(count == 2) + src(2) + CHECK(count == 4) end - do CASE "run watcher callback twice" - local state = source(1) - local runcount = 0 + do CASE "run effect callback twice" + local src = source(1) + local count = 0 - watch(function() - runcount += 1 - state() + effect(function() + count += 1 + src() end) - CHECK(runcount == 2) - state(2) - CHECK(runcount == 4) + CHECK(count == 2) + src(2) + CHECK(count == 4) end do CASE "indexes() error if primitive" - local state = source { 1 } + local src = source { 1 } local ok = pcall(function() - indexes(state, function() return 1 end) + indexes(src, function() return 1 end) end) CHECK(not ok) end do CASE "values() error if duplicate" - local state = source { 1, 2, 1 } + local src = source { 1, 2, 1 } local ok = pcall(function() - values(state, function() return {} end) + values(src, function() return {} end) end) CHECK(not ok) @@ -1532,16 +1641,7 @@ TEST("strict", function() CHECK(ok) end - - do CASE "multiple cleanup per scope" - local ok = pcall(function() - cleanup(function() end) - cleanup(function() end) - end) - - CHECK(not ok) - end -end) +end)) local ok = FINISH() if not ok then error("Tests failed", 0) end diff --git a/todo.md b/todo.md index 250bfb3..0d59b74 100644 --- a/todo.md +++ b/todo.md @@ -1,23 +1,13 @@ # todo -- better error reporting and stack traces in strict mode - auto-enable of strict mode depending on compiler optimizaton level -- investigate if weak table iteration can be invalidated -- have derived sources/bindings track sources dynamically? - - solves case where sources are used in if-branching guarded by another - source - - significantly reduces performance -- solution to component cleanup - - rely on `Instance.Destroying` event and manual destruction when cleanup is - needed? - - expand behavior of `vide.cleanup()` to detect garbage collection of - arbitrary values, not needing manual destruction - - look into SolidJS's reactive contexts +- property binding optimization + - would no longer allow `cleanup()` usage in binding scopes - solution to nested reactivity, see: SolidJS stores -- SolidJS control flow components - - Show - - Switch - - Dynamic +- investigate performance of wide graphs +- optimize child removal +- implement from solid: - Portal -- batch source updates + - batch - optimize `indexes()` double-diffing +- define behavior of deriving a source within a derived source