From d1da0b4ea55502295a1fa5007898d9f6243a11f6 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 18 Sep 2023 16:19:53 -0700 Subject: [PATCH 01/56] Add `show()` --- src/init.luau | 2 ++ src/show.luau | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/show.luau diff --git a/src/init.luau b/src/init.luau index 8958dd9..ce28c8c 100644 --- a/src/init.luau +++ b/src/init.luau @@ -15,6 +15,7 @@ local cleanup = require(script.cleanup) local untrack = require(script.untrack) local derive = require(script.derive) local switch = require(script.switch) +local show = require(script.show) local indexes, values = require(script.maps)() local spring, update_springs = require(script.spring)() local action = require(script.action)() @@ -51,6 +52,7 @@ local vide = { effect = effect, derive = derive, switch = switch, + show = show, indexes = indexes, values = values, diff --git a/src/show.luau b/src/show.luau new file mode 100644 index 0000000..1cf60ca --- /dev/null +++ b/src/show.luau @@ -0,0 +1,18 @@ +if not game then script = require "test/relative-string" end + +local switch = require(script.Parent.switch) + +local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? + local function truthy() + return not not source() + end + + return switch(truthy) { + [true] = component, + [false] = fallback, + } +end + +return show :: + ((source: () -> any, component: () -> T) -> () -> T?) & + ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) From 250c13e7b894414e6c56080bb272548a0248b3b6 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 19 Sep 2023 12:07:49 +0100 Subject: [PATCH 02/56] Fix edge case bugs in reactive graph updates --- src/graph.luau | 28 +++++++++++++----- test/tests.luau | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index 77db497..f911f7b 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -127,7 +127,7 @@ local function destroy(node: Node) while node[1] do destroy(node[1]) end end -local update_queue = {} :: { Node } +local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) local cur_value = node.cache @@ -152,6 +152,7 @@ local function evaluate_node(node: Node) if not ok then table.clear(update_queue) + update_queue.n = 0 throw(`side-effect error from source update\n{new_value}`) end @@ -160,7 +161,6 @@ local function evaluate_node(node: Node) return cur_value ~= new_value -- node has changed value end --- todo: case where owner is set from an untrack call within an effectful node, children clearing local function update_from(node: StartNode, n0: number) if not node[1] then return end @@ -168,20 +168,32 @@ local function update_from(node: StartNode, n0: number) -- unparent all children and queue for eval do - local child = node[1] - while child do -- todo: case where child in owner context + local i = 1 + local child = node[i] + while child do unparent(child) n += 1 update_queue[n] = child - child = node[1] + local next_child = node[i] + + -- children who have this parent as an owner will not be unparented + -- if such a child is encountered then skip it + if next_child == child then + i += 1 + next_child = node[i] + end + + child = next_child end end + update_queue.n = n + -- evaluate all queued children for i = n0 + 1, n do - local child = update_queue[i] -- todo: error: index boolean + local child = update_queue[i] if not child.effect then continue end if evaluate_node(child) then @@ -190,10 +202,12 @@ local function update_from(node: StartNode, n0: number) update_queue[i] = false :: any -- false instead of nil to avoid sparse end + + update_queue.n = n0 end local function update(node: StartNode) - update_from(node, 0) + update_from(node, update_queue.n) end local function track(node: StartNode) diff --git a/test/tests.luau b/test/tests.luau index c8844f8..1e5e074 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -253,6 +253,47 @@ TEST("graph", function() gc() CHECK(not wref[1]) end + + do CASE "recursive update" + --[[ + + A -> B + C + D -> E + F + + B updates D + + depth=1 + B, C + ^ + + depth=2 + E, F + ^ + + depth=2 + _, F + ^ + + depth=1 + _, _ <- attempt to update nothing + ^ + + ]] + + local a, b, c, d, e, f = node(), node(), node(), node(), node(), node() + + function b.effect(x) + update(d) + return not x + end + + add_child(a, b); add_child(a, c) + add_child(d, e); add_child(d, f) + + update(a) + + CHECK(true) + end end) TEST("mount()", function() @@ -375,6 +416,7 @@ TEST("derive()", wrap_root(function() local derive = vide.derive local effect = vide.effect local cleanup = vide.cleanup + local untrack = vide.untrack do CASE "derive new value on source change" local a = source(1) @@ -480,6 +522,39 @@ TEST("derive()", wrap_root(function() CHECK(count == 2) end + do CASE "child with parent as owner not lost" + local num = source(0) + + local cleaned = {} + + local destroy = vide.mount(function() + local owner = derive(function() + local i = num() + + return untrack(function() + return derive(function() + cleanup(function() + cleaned[i] = true + end) + return i + end) + end) + end) + + local child1 = owner() + num(1) + local child2 = owner() + + CHECK(child1() == 0) + CHECK(child2() == 1) + end) + + destroy() + + CHECK(cleaned[0]) + CHECK(cleaned[1]) + end + do CASE "garbage collection" -- check that `b` does not allow gc of `a` local a = source(1) From 520b0cca3269e2c3c16077feb180775990a121db Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 19 Sep 2023 12:20:03 +0100 Subject: [PATCH 03/56] Update `indexes()` test --- test/tests.luau | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/tests.luau b/test/tests.luau index 1e5e074..f3ad794 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1031,6 +1031,7 @@ end)) TEST("indexes()", wrap_root(function() local create = vide.create local source = vide.source + local effect = vide.effect local indexes = vide.indexes local cleanup = vide.cleanup @@ -1166,6 +1167,42 @@ TEST("indexes()", wrap_root(function() CHECK(n0 == n1) end + + -- practical example based on the graph - recursive update test + do CASE "recursive update" + local items = source { 1 } + + local updated = table.create(100, 0) + + local elements = indexes(items, function(item) + effect(function() + item() + updated[1] += 1 + end) + + effect(function() + item() + updated[2] += 1 + end) + end) + + effect(function() + items() + updated[3] += 1 + end) + + effect(function() + items() + updated[4] += 1 + end) + + items { 2 } + + CHECK(updated[1] == 2) + CHECK(updated[2] == 2) + CHECK(updated[3] == 2) + CHECK(updated[4] == 2) + end end)) TEST("values()", wrap_root(function() From bb08e2e97a9e4fddd78d1259d041023393c25596 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 19 Sep 2023 14:52:28 +0100 Subject: [PATCH 04/56] Update crash course --- docs/.vitepress/config.ts | 16 ++-- docs/tut/crash-course/1-introduction.md | 5 -- ...erty-nesting.md => 10-property-nesting.md} | 5 +- .../{10-actions.md => 11-actions.md} | 17 +++- docs/tut/crash-course/12-strict-mode.md | 9 ++ docs/tut/crash-course/2-creation.md | 7 +- docs/tut/crash-course/3-components.md | 12 ++- docs/tut/crash-course/4-source.md | 50 +++++------ docs/tut/crash-course/5-effect.md | 86 +++++++++++++------ docs/tut/crash-course/6-derived-source.md | 76 ---------------- docs/tut/crash-course/6-stateful-component.md | 66 ++++++++++++++ docs/tut/crash-course/7-property-binding.md | 64 ++++++++++++++ .../{7-cleanup.md => 8-cleanup.md} | 14 ++- .../{8-control-flow.md => 9-control-flow.md} | 4 + todo.md | 1 + 15 files changed, 272 insertions(+), 160 deletions(-) rename docs/tut/crash-course/{9-property-nesting.md => 10-property-nesting.md} (94%) rename docs/tut/crash-course/{10-actions.md => 11-actions.md} (66%) create mode 100644 docs/tut/crash-course/12-strict-mode.md delete mode 100644 docs/tut/crash-course/6-derived-source.md create mode 100644 docs/tut/crash-course/6-stateful-component.md create mode 100644 docs/tut/crash-course/7-property-binding.md rename docs/tut/crash-course/{7-cleanup.md => 8-cleanup.md} (63%) rename docs/tut/crash-course/{8-control-flow.md => 9-control-flow.md} (94%) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 519d4d6..b889dc8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -41,13 +41,15 @@ export default defineConfig({ { text: "Introduction", link: "/tut/crash-course/1-introduction" }, { text: "Element Creation", link: "/tut/crash-course/2-creation" }, { text: "Components", link: "/tut/crash-course/3-components" }, - { text: "Source", link: "/tut/crash-course/4-source" }, - { text: "Effect", link: "/tut/crash-course/5-effect" }, - { text: "Derived Source", link: "/tut/crash-course/6-derived-source" }, - { text: "Cleanup", link: "/tut/crash-course/7-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/8-control-flow" }, - { text: "Property Nesting", link: "/tut/crash-course/9-property-nesting" }, - { text: "Actions", link: "/tut/crash-course/10-actions" }, + { text: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Stateful Components", link: "/tut/crash-course/6-stateful-component" }, + { text: "Property Binding", link: "/tut/crash-course/7-property-binding" }, + { text: "Cleanup", link: "/tut/crash-course/8-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/9-control-flow" }, + { text: "Property Nesting", link: "/tut/crash-course/10-property-nesting" }, + { text: "Actions", link: "/tut/crash-course/11-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/12-strict-mode" }, ] }, { diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 7b00dac..371950a 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -16,11 +16,7 @@ worrying about manually updating UI instances. Some of the main focuses behind Vide's design choices: - Concise syntax to reduce verbosity as much as possible. -- Reducing the amount of imports needed for usage by leveraging Luau's syntax - and semantics. - Being completely typecheckable. -- Flexibility with integrating other libraries and allowing users to use their - own patterns. - Independence from instance lifetimes. - A powerful reactive system that can update specific properties as a result of state changes, updates are immediate with no diffing needed. @@ -36,7 +32,6 @@ 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" } diff --git a/docs/tut/crash-course/9-property-nesting.md b/docs/tut/crash-course/10-property-nesting.md similarity index 94% rename from docs/tut/crash-course/9-property-nesting.md rename to docs/tut/crash-course/10-property-nesting.md index 1a61425..2aabb11 100644 --- a/docs/tut/crash-course/9-property-nesting.md +++ b/docs/tut/crash-course/10-property-nesting.md @@ -77,7 +77,8 @@ be parented. ```lua type Children = { - Children = Array + -- allows us to also optionally pass a source that returns an array of children instead + Children = Array | () -> Array } local function List(props: Children & Layout) @@ -107,8 +108,8 @@ properties, this can be used to create overridable default properties. local function List(props: Children & Layout) return create "Frame" { props.Children, - props.Layout, + -- can be overriden by `props.Layout` AnchorPoint = Vector2.new(0.5, 0), Position = UDim2.fromScale(0.5, 0), diff --git a/docs/tut/crash-course/10-actions.md b/docs/tut/crash-course/11-actions.md similarity index 66% rename from docs/tut/crash-course/10-actions.md rename to docs/tut/crash-course/11-actions.md index 68fb06e..79ca7c7 100644 --- a/docs/tut/crash-course/10-actions.md +++ b/docs/tut/crash-course/11-actions.md @@ -24,19 +24,32 @@ Actions can be wrapped with functions to re-use specific behaviors. Below is an example of an action used to listen for property changes: ```lua +local action = vide.action +local cleanup = vide.cleanup + local function changed(property: string, callback: (new) -> ()) return action(function(instance) - instance:GetPropertyChangedSignal(property):Connect(function() + local con = instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) + + -- remember to clean up the connection when the reactive scope the action + -- is ran in is destroyed, so the instance can be garbage collected + cleanup(function() + con:Disconnect() + end) end) end local output = source "" -create "TextBox" { +local instance = create "TextBox" { changed("Text", output) } + +instance.Text = "foo" + +print(output()) -- "foo" ``` The source `output` will be updated with the new property value any time it is diff --git a/docs/tut/crash-course/12-strict-mode.md b/docs/tut/crash-course/12-strict-mode.md new file mode 100644 index 0000000..c66af04 --- /dev/null +++ b/docs/tut/crash-course/12-strict-mode.md @@ -0,0 +1,9 @@ +# Strict Mode + +While developing UI with Vide, you should use Vide's strict mode, which can +be set with `vide.strict = true` once when you first require Vide. Strict mode +will add extra safety checks and emit better error traces, particularly when +errors occur in property bindings. + +A full list of what strict mode will do can be found +[here](../../api/strict-mode). diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index deb1683..68e3007 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -9,7 +9,6 @@ Luau allows us to omit parentheses `()` when calling functions with string or table literals which Vide takes advantage of for brevity. ```lua -local vide = require(vide) local mount = vide.mount local create = vide.create @@ -56,3 +55,9 @@ create "Frame" { UDim2 = { 0.5, 0, 0.5, 0 } } ``` + +When creating an instance with no properties, it is important to not forget to +actually call the constructor: `create "Frame" {}` and not `create "Frame"`. +To be clear, `create "Frame"` returns a *function* which is a constructor for +that class, not an instance of that class. This would result in you attempting +to parent a function instead of an instance which is not the correct behavior. diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 712ab9a..3050cf8 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -6,7 +6,6 @@ By using components you can make your application more modular and better organized. ```lua [Button.luau] -local vide = require(vide) local create = vide.create local function Button(props: { @@ -28,7 +27,6 @@ return Button ``` ```lua [App.luau] -local vide = require(vide) local mount = vide.mount local create = vide.create @@ -56,11 +54,11 @@ being reused across files. A single parameter `props` is used to pass properties to the component. Components allow you to *encapsulate* behavior. You can only modify the -component in ways that you allow in the component. +component in ways that you allow in the component, through the `props` parameter. -This also promotes code reusability. Anytime you want a new button all you do -is call `Button {}` instead of creating and setting every property each time. -When changing the button in future, any changes to the button file will be -reflected anywhere the button is used throughout your app. +To create a new button all you must do is call the `Button` function, passing in +values through props. This saves having to create and set every property each +time. Also, when updating the button component in future, any changes to the +button file will be seen anywhere the button is used in your app. 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 cf7ceb2..1583c59 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,31 +1,15 @@ # Source *Sources* in Vide are special objects that store a single value. They are the -core of reactivity in Vide, as updates to a source can automatically update -properties or other sources depending on that source. +core of reactivity in Vide. Each source represents a source of data, and they +can be composed and derived to create new sources of data. A source in Vide can be created using `source()`. ```lua -local vide = require(vide) local source = vide.source -local function Counter() - local count = source(0) - - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = count, - - Activated = function() - count(count() + 1) - end - } -end - -mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) +local count = source(0) ``` The value passed to `source()` is the initial value of the source. @@ -37,15 +21,23 @@ by calling it with no arguments. count(count() + 1) -- increment count by 1 ``` -Each call of `Counter {}` will create a new counter, each maintaining their -own count. +Sources can be *derived* by wrapping them in functions. A wrapped source +effectively becomes a new source. -When you assign a function to a non-event property, Vide will immediately run it -and check what sources were read from. When updating those sources again after, -this function will be re-ran and its return value applied to the property. -This is known as *binding* properties. +```lua +local count = source(0) -This allows you as the programmer to not need to manually update UI as the state -of your program changes. You just define how the data maps to UI, and Vide's -reactive system will automatically update any properties depending on sources -that are updated. +local text = function() + return "count: " .. tostring(count()) +end + +print(text()) -- "count: 0" +count(1) +print(text()) -- "count: 1" +``` + +You may be wondering why we are using sources instead of plain variables to do +this. The reason is that Vide has an entire reactive system based on sources. +You can write functions to automatically run each time a source is updated. This +can be to update properties, create new instances, print to the terminal, etc. +How this is done will be covered next. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 67c1f58..3891652 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,45 +1,75 @@ # 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 functions that are ran in response to source updates. They are +alled effects because they cause *side-effects* when reacting to source updates. Effects are created using `effect()`. ```lua -local vide = require(vide) local source = vide.source local effect = vide.effect -local function Counter() - local count = source(0) +local count = source(0) - effect(function() - print("count has updated to: " .. count()) - end) +effect(function() + print("count: " .. count()) +end) - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = count, - - Activated = function() - count(count() + 1) - end - } -end - -mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) +-- "count: 0" printed +count(1) +-- "count: 1" printed ``` -This will print to the terminal anytime the count is changed. +The callback given to `effect()` is ran in a *reactive-scope*. Any source read +from inside a reactive scope will be tracked, so that if any of those sources +update, the effect will be re-ran too. -`effect()` creates an explicit side-effect. There are other side-effects in the -above code sample. The setting of `Text = count` creates another side-effect; -the updating of the Text property anytime the count is changed. +The callback is first ran immediately inside the `effect()` call to initially +figure out what sources are being used. -All observable changes to the user are considered to be side-effects of the -reactive system. +Effects also work with derived sources, it doesn't matter how deeply nested a +source is. + +```lua +local source = vide.source +local effect = vide.effect + +local count = source(1) + +local doubled = function() + return count() * 2 +end + +effect(function() + print("doubled count: " .. doubled()) +end) + +-- "doubled count: 2" printed +count(2) +-- "doubled count: 4" printed +``` + +Derived sources should be a *pure computation*. A pure computation is one where +the same input will always produce the same output. + +All observable changes to the user are considered to be side-effects of pure +computations. + +Sources, derived sources, and effects form what is called a *reactive graph*. +In the above example a graph `count -> doubled -> effect` is formed. Anywhere +an update occures, everything further down the graph is updated. You should not update other sources using an effect. Improper usage can lead to -unecessary updates and infinite loops. +a cyclic loop in the graph, causing an infinite loop when it tries to update. +Sources should be derived instead. + +## Root Reactive Scopes + +Effects must be created within another reactive scope. This is so that the +effect itself can be tracked and later freed when the parent reactive scope is +destroyed, such as from unmounting an app. The example code above will not +actually work unless it is ran inside a root reactive scope, such as one created +by `vide.mount(function)`. This generally isn't a concern since you can assume +that all your components will be created within a single `mount()` call, which +happens only once at the top level, where you put together your UI and parent it +to a ScreenGUI. diff --git a/docs/tut/crash-course/6-derived-source.md b/docs/tut/crash-course/6-derived-source.md deleted file mode 100644 index 01fe1c2..0000000 --- a/docs/tut/crash-course/6-derived-source.md +++ /dev/null @@ -1,76 +0,0 @@ -# Derived Source - -You can create new sources from existing sources. This is known as *deriving -sources*. - -A function that wraps a source effectively becomes a new source. If a source -used inside a function is updated, the whole function can be re-ran to recompute -its value. - -```lua -local vide = require(vide) -local source = vide.source - -local function Counter() - local count = source(0) - - local function doubled() - return count() * 2 - end - - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = doubled, - - Activated = function() - count(count() + 1) - end - } -end - -mount(function() return create "ScreenGui" { Counter {} } end, game.StarterGui) -``` - -Now the counter will increment in 2s each time it is clicked. - -Sometimes when using expensive computations to derive state, you only want to -recalculate it once when a source state has changed. Although not needed in -most cases, you can use `derive()` to create a new source that will cache its -value, only recomputing when an input source has changed. - -```lua -local vide = require(vide) -local source = vide.source -local derive = vide.derive - -local function Counter() - local count = source(0) - - local factorial = derive(function() - local n = 1 - for i = 2, count() do - n *= i - end - return n - end) - - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = function() - return factorial() + factorial() + factorial() - end, - - Activated = function() - count(count() + 1) - end - } -end -``` - -This can improve performance in cases where a source is read from multiple times -between recalculations. In the above example, the factorial is only ever -calculated once each time the count changes. diff --git a/docs/tut/crash-course/6-stateful-component.md b/docs/tut/crash-course/6-stateful-component.md new file mode 100644 index 0000000..ba167e2 --- /dev/null +++ b/docs/tut/crash-course/6-stateful-component.md @@ -0,0 +1,66 @@ +# Stateful Component + +A stateful component is a component that stores and displays some data. + +Stateful components in Vide are created using sources and effects - sources to +store the data, and effects to display the data. + +```lua +local create = vide.create +local source = vide.source +local effect = vide.effect + +local function Counter() + local count = source(0) + + local instance = create "TextButton" { + Activated = function() + count(count() + 1) + end + } + + effect(function() + instance.Text = "count: " .. count() + end) + + return count +end +``` + +Above is an example of a counter component, that when clicked, will increment +its internal count, and automatically update its text to reflect that count. + +Each instance of `Counter()` will maintain its own independent count, since the +count source is created inside the scope of the component. + +External sources can also be passed into components for them to use. + +```lua +local function Counter(props: { count: () -> number }) + local count = props.count + + local instance = create "TextButton" { + Activated = function() + count(count() + 1) + end + } + + effect(function() + instance.Text = "count: " .. count() + end) + + return count +end + +local count = source(0) + +Counter { + count = count +} + +count(1) -- the Counter component will update to display this count +``` + +Sources can be created internally or passed in from externally, there are no +restrictions on how they are used as long as the effect is created within a +reactive scope so that it can be tracked. diff --git a/docs/tut/crash-course/7-property-binding.md b/docs/tut/crash-course/7-property-binding.md new file mode 100644 index 0000000..b3840cc --- /dev/null +++ b/docs/tut/crash-course/7-property-binding.md @@ -0,0 +1,64 @@ +# Property Binding + +Explicitly creating effects to update properties can become verbose when there +are a lot of properties to update. Vide provides a way to *implicitly* create +an effect to update properties on source update. This is also known as +*property binding*, as a property is binded to reflect some data. + +```lua +local create = vide.create +local source = vide.source + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = function() + return "count: " .. count() + end, + + Activated = function() + count(count() + 1) + end + } +``` + +This example is equivalent to the example seen on the previous page. + +Instead of explicitly creating an effect, assigning a (non-event) property +a function will implicitly create a side-effect to update that property anytime +a dependent source is updated. + +Just like effects, the function is ran immediately in a reactive-scope to set +the property initially and determine what sources are being depended on. + +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. + +## Children Binding + +Children can also be set in a similar manner. + +```lua +local items = source { + create "TextLabel" { Text = "A" } +} + +local function List(props: { children: () -> { Instance } }) + return create "Frame" { + create "UIListLayout" {}, + props.children + } +end + +local list = List { children = items } -- creates a list with a single text label "A" + +items { + create "TextLabel" { Text = "B" }, + create "TextLabel" { Text = "C" } +} + +-- this will automatically unparent the text label "A", and parent the labels "B" and "C". +``` diff --git a/docs/tut/crash-course/7-cleanup.md b/docs/tut/crash-course/8-cleanup.md similarity index 63% rename from docs/tut/crash-course/7-cleanup.md rename to docs/tut/crash-course/8-cleanup.md index 3e5525d..3a04463 100644 --- a/docs/tut/crash-course/7-cleanup.md +++ b/docs/tut/crash-course/8-cleanup.md @@ -6,7 +6,7 @@ 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) +locla mount = vide.mount local source = vide.source local cleanup = vide.cleanup @@ -26,15 +26,23 @@ local function Timer() Size = UDim2.fromOffset(200, 50), Text = function() - return "seconds: " .. count() + return "seconds: " .. math.floor(count()) end, } end -mount(function() return create "ScreenGui" { Timer {} } end, game.StarterGui) +local unmount = mount(Timer) + +unmount() -- all registered cleanups are ran, heartbeat connection stopped ``` 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. + +On a related note: the reason why `mount()` is used to create your app, is so +that any top-level components that need to be cleaned up, can be cleaned up +when the app is later unmounted, since `mount()` runs in a reactive-scope to +track `cleanup()` calls. Vide's entire reactive system is independent from the +life-time of instances; instances are just a side-effect of the reactive system. diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/9-control-flow.md similarity index 94% rename from docs/tut/crash-course/8-control-flow.md rename to docs/tut/crash-course/9-control-flow.md index f590145..fdde0ae 100644 --- a/docs/tut/crash-course/8-control-flow.md +++ b/docs/tut/crash-course/9-control-flow.md @@ -52,6 +52,10 @@ 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. +The callbacks given to control flow functions are ran in a new reactive-scope, +so any cleanups registered will be ran when the input is changed and a new +output is created. + Another control flow function, `indexes()`, is used to create elements from an input table. diff --git a/todo.md b/todo.md index 9fb48b6..933d2b5 100644 --- a/todo.md +++ b/todo.md @@ -12,3 +12,4 @@ - optimize `indexes()` double-diffing - define behavior of deriving a source within a derived source - review destruction of node under a root that has a child in another root +- check in strict mode for forgetting to call constructor `create(class)` From 299ebfc0ac5a58111452b3291b3c7b2a15bf6cb4 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:01:26 +0100 Subject: [PATCH 05/56] Update strict mode docs --- docs/api/strict-mode.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 0472954..91be656 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -16,8 +16,7 @@ Currently, strict mode will: 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. +6. Better error reporting and stack traces + creation traces of property bindings. By rerunning sources and effects, any side-effects are made more apparent. This also helps ensure that cleanups are being handled correctly. From d8a627c58ab05de3b2a57174f7ded7770ce16a35 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 19 Sep 2023 17:37:42 +0100 Subject: [PATCH 06/56] Fix test type error --- test/tests.luau | 4 +++- todo.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/tests.luau b/test/tests.luau index f3ad794..b0e7e67 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1174,7 +1174,7 @@ TEST("indexes()", wrap_root(function() local updated = table.create(100, 0) - local elements = indexes(items, function(item) + indexes(items, function(item) effect(function() item() updated[1] += 1 @@ -1184,6 +1184,8 @@ TEST("indexes()", wrap_root(function() item() updated[2] += 1 end) + + return {} end) effect(function() diff --git a/todo.md b/todo.md index 933d2b5..bac72b2 100644 --- a/todo.md +++ b/todo.md @@ -13,3 +13,4 @@ - define behavior of deriving a source within a derived source - review destruction of node under a root that has a child in another root - check in strict mode for forgetting to call constructor `create(class)` +- improve crash course, some sections feel like information dumps From f733647031120058bf4923292069afc543553409 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 18 Sep 2023 19:07:54 -0700 Subject: [PATCH 07/56] Add `read()` --- src/init.luau | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/init.luau b/src/init.luau index ce28c8c..84d2ae4 100644 --- a/src/init.luau +++ b/src/init.luau @@ -59,6 +59,9 @@ local vide = { -- util cleanup = cleanup, untrack = untrack, + read = function(value: T | () -> T): T + return if type(value) == "function" then value() else value + end, -- animations spring = spring, From 109cca45f306d7b4786b1a14fb1b37c0c1f2c791 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 18 Sep 2023 19:44:48 -0700 Subject: [PATCH 08/56] Echo initial value if given a source --- src/source.luau | 6 +++++- test/tests.luau | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/source.luau b/src/source.luau index f1307fc..87b144e 100644 --- a/src/source.luau +++ b/src/source.luau @@ -9,6 +9,10 @@ local update = graph.update export type Source = (() -> T) & ((T) -> T) local function source(initial_value: T): Source + if type(initial_value) == "function" then + return initial_value + end + local node = create_start_node(initial_value) return function(...): T @@ -28,4 +32,4 @@ local function source(initial_value: T): Source end end -return source :: ((initial_value: T) -> Source) & (() -> Source) +return source :: ((source: Source) -> Source) & ((initial_value: T) -> Source) & (() -> Source) diff --git a/test/tests.luau b/test/tests.luau index b0e7e67..4e79fff 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -359,6 +359,12 @@ TEST("source()", wrap_root(function() CHECK(src() == 2) end + do CASE "echo if value is a source" + local a = source(1) + local b = source(a) + CHECK(a == b) + end + do CASE "does not update if same value" local src = source(1) From a543a2486597baa319470b9a133aa9810caf2ee4 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 18 Sep 2023 19:58:39 -0700 Subject: [PATCH 09/56] Revert "Echo initial value if given a source" This reverts commit 16e2983c54758cabfb1ddd102090f20083820d03. --- src/source.luau | 6 +----- test/tests.luau | 6 ------ 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/source.luau b/src/source.luau index 87b144e..f1307fc 100644 --- a/src/source.luau +++ b/src/source.luau @@ -9,10 +9,6 @@ local update = graph.update export type Source = (() -> T) & ((T) -> T) local function source(initial_value: T): Source - if type(initial_value) == "function" then - return initial_value - end - local node = create_start_node(initial_value) return function(...): T @@ -32,4 +28,4 @@ local function source(initial_value: T): Source end end -return source :: ((source: Source) -> Source) & ((initial_value: T) -> Source) & (() -> Source) +return source :: ((initial_value: T) -> Source) & (() -> Source) diff --git a/test/tests.luau b/test/tests.luau index 4e79fff..b0e7e67 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -359,12 +359,6 @@ TEST("source()", wrap_root(function() CHECK(src() == 2) end - do CASE "echo if value is a source" - local a = source(1) - local b = source(a) - CHECK(a == b) - end - do CASE "does not update if same value" local src = source(1) From 835f4d429b9e4f0ffd54f5473534c4576f2c1044 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 11:39:44 +0100 Subject: [PATCH 10/56] Add check for unstable spring parameters --- src/spring.luau | 5 +++++ test/spring-test.luau | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index e60e3ae..66dc896 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -161,6 +161,11 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local c_c = 2*w_n local c = z * c_c + -- todo: is there a solution to this other than upping step frequency? + if c > UPDATE_RATE*2 then -- solver will explode if this is true + throw("spring damping too high, consider reducing damping or increasing period") + end + local data: SpringData = { k = k, c = c, diff --git a/test/spring-test.luau b/test/spring-test.luau index c22d955..2e3bc9b 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -48,14 +48,14 @@ local function main() local reset = "\27[H\27[2J" -- ANSI clear terminal local offset = string.rep("\n", MAX - fv + OFFSET) local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv)) - print(reset .. offset .. bar) + print(reset .. offset .. bar .. "\n" .. v) end) - local elapsed = 0 + local T = 3 + local elapsed = T/1.2 repeat local dt = step() vide.step(dt) - local T = 3 elapsed += dt while elapsed >= T do elapsed -= T From 8b4210e66ac0fac41583ed3eb429b088bb64d99b Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 12:11:29 +0100 Subject: [PATCH 11/56] Add error handling for no properties specified --- src/apply.luau | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apply.luau b/src/apply.luau index d0bccf7..1cc533a 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -118,6 +118,10 @@ end -- applies table of nested properties to an instance using full vide semantics local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T + if not properties then + throw("no properties given, did you forget to call the constructor returned by create()?") + end + -- queue parent assignment if any for last local parent: unknown = properties.Parent if parent then properties.Parent = nil end From cc4b390d3d665b1e8634377fbe89d89d6daa8804 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 12:23:51 +0100 Subject: [PATCH 12/56] Auto strict mode based on optimization level --- src/flags.luau | 8 +++++++- src/init.luau | 38 ++++++++++++++++---------------------- test/benchmark.luau | 2 ++ test/tests.luau | 4 ++++ todo.md | 7 ++----- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/flags.luau b/src/flags.luau index 962301d..1b9f80e 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -1 +1,7 @@ -return { strict = false } +local function inline_test(): string + return debug.info(1, "n") +end + +local is_O2 = inline_test() ~= "inline_test" + +return { strict = not is_O2 } diff --git a/src/init.luau b/src/init.luau index 84d2ae4..62b6281 100644 --- a/src/init.luau +++ b/src/init.luau @@ -91,28 +91,22 @@ local vide = { end } -do - local set = false - - setmetatable(vide :: any, { - __index = function(_, index: unknown): () - if index == "strict" then - return flags.strict - else - throw(`{tostring(index)} is not a valid member of vide`) - end - end, - - __newindex = function(_, index: unknown, value: unknown) - if index == "strict" then - if set then throw "strict mode has already been set" end - set = true - flags.strict = value :: boolean - else - throw(`{tostring(index)} is not a valid member of vide`) - end +setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if index == "strict" then + return flags.strict + else + throw(`{tostring(index)} is not a valid member of vide`) end - }) -end + end, + + __newindex = function(_, index: unknown, value: unknown) + if index == "strict" then + flags.strict = value :: boolean + else + throw(`{tostring(index)} is not a valid member of vide`) + end + end +}) return vide diff --git a/test/benchmark.luau b/test/benchmark.luau index 6702235..ca9c436 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -9,6 +9,8 @@ local values = vide.values local cleanup = vide.cleanup local create = vide.create +assert(not vide.strict) + local function TITLE(name: string) print() print(testkit.color.white(name)) diff --git a/test/tests.luau b/test/tests.luau index b0e7e67..a12df38 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -31,6 +31,8 @@ end local NIL = nil :: any +vide.strict = false + TEST("graph", function() local create_node = graph.create_node local track = graph.track @@ -1644,6 +1646,8 @@ TEST("changed()", wrap_root(function() end end)) +vide.strict = true + TEST("strict", wrap_root(function() vide.strict = true diff --git a/todo.md b/todo.md index bac72b2..5b9fc47 100644 --- a/todo.md +++ b/todo.md @@ -1,16 +1,13 @@ # todo -- auto-enable of strict mode depending on compiler optimizaton level - property binding optimization - would no longer allow `cleanup()` usage in binding scopes - solution to nested reactivity, see: SolidJS stores -- investigate performance of wide graphs -- optimize child removal +- optimize wide graph updating - implement from solid: - Portal - batch - optimize `indexes()` double-diffing - define behavior of deriving a source within a derived source -- review destruction of node under a root that has a child in another root -- check in strict mode for forgetting to call constructor `create(class)` +- define behavior of node destruction under a root that has a child in another root - improve crash course, some sections feel like information dumps From 97096f8affec9b10f6e772a6e2a14ba7792c879f Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 18:42:02 +0100 Subject: [PATCH 13/56] Add tests for `show()` and `read()` --- test/tests.luau | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/tests.luau b/test/tests.luau index a12df38..d9600ef 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -920,6 +920,22 @@ TEST("create()", wrap_root(function() end end)) +TEST("show()", wrap_root(function() + -- uses switch() internally, more extensive testing of scoping not needed + local source = vide.source + local show = vide.show + + local value = source("truey" :: unknown) + local function one() return 1 end + local function two() return 2 end + + local output = show(value, one, two) + + CHECK(output() == 1) + value(nil) + CHECK(output() == 2) +end)) + TEST("switch()", wrap_root(function() local source = vide.source local switch = vide.switch @@ -1646,6 +1662,34 @@ TEST("changed()", wrap_root(function() end end)) +TEST("read()", wrap_root(function() + local source = vide.source + local effect = vide.effect + local read = vide.read :: any -- todo + + do CASE "read primitive" + CHECK(read(1) == 1) + end + + do CASE "read source" + local src = source(1) :: () -> number + CHECK(read(src) == 1) + end + + do CASE "track source" + local src = source(0) + + local count = 0 + effect(function() + read(src) + count += 1 + end) + + src(1) + CHECK(count == 2) + end +end)) + vide.strict = true TEST("strict", wrap_root(function() From 85b112755136e399cd7021615f2edafbf0fc648a Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 18:42:29 +0100 Subject: [PATCH 14/56] Update docs --- docs/.vitepress/config.ts | 12 +-- docs/api/reactivity-flow.md | 114 +++++++++++++------- docs/api/reactivity-utility.md | 24 ++--- docs/api/strict-mode.md | 11 +- docs/tut/{ => advanced}/reactive-scoping.md | 0 docs/tut/control-flow/1-intro.md | 0 docs/tut/control-flow/2-show.md | 1 + docs/tut/control-flow/3-switch.md | 1 + docs/tut/control-flow/4-indexes.md | 1 + docs/tut/control-flow/5-values.md | 1 + docs/tut/control-flow/indexes.md | 66 ------------ docs/tut/control-flow/switch.md | 66 ------------ docs/tut/control-flow/values.md | 66 ------------ docs/tut/crash-course/12-strict-mode.md | 22 ++++ docs/tut/crash-course/9-control-flow.md | 113 +++++++++++++------ 15 files changed, 200 insertions(+), 298 deletions(-) rename docs/tut/{ => advanced}/reactive-scoping.md (100%) create mode 100644 docs/tut/control-flow/1-intro.md create mode 100644 docs/tut/control-flow/2-show.md create mode 100644 docs/tut/control-flow/3-switch.md create mode 100644 docs/tut/control-flow/4-indexes.md create mode 100644 docs/tut/control-flow/5-values.md delete mode 100644 docs/tut/control-flow/indexes.md delete mode 100644 docs/tut/control-flow/switch.md delete mode 100644 docs/tut/control-flow/values.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b889dc8..ae4c9e5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -53,17 +53,9 @@ export default defineConfig({ ] }, { - text: "Control Flow WIP", + text: "Advanced Reactivity", items: [ - { text: "switch", link: "/tut/control-flow/switch.md" }, - { text: "indexes", link: "/tut/control-flow/indexes.md" }, - { text: "values", link: "/tut/control-flow/values.md" }, - ] - }, - { - text: "Advanced Reactivity WIP", - items: [ - { text: "reactive-scopes", link: "/tut/reactive-scoping.md"} + { text: "Reactive Scopes", link: "/tut/advanced/reactive-scoping.md"} ] } ], diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md index 58e2200..47dafa8 100644 --- a/docs/api/reactivity-flow.md +++ b/docs/api/reactivity-flow.md @@ -2,9 +2,33 @@
+## show() + +Shows one of two components depending on an input source. + +- **Type** + + ```lua + function show(source: () -> unknown, component: () -> T): () -> T? + function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U + ``` + +- **Details** + + Returns a source holding an instance of the currently shown component. + + When the input source changes from a falsey to a truthy value, the + component will be reran under a new reactive scope. If it changes from a + truthy to falsey value, the reactive scope the component was created in will + be destroyed, and the returned source will output `nil`, or a fallback + component if given. + + The fallback component is also ran under a new reactive scope, and destroyed + when the input source switches back to truthy. + ## switch() -Changes object based on a source and a mapping table. +Shows one of a set of components depending on an input source and a mapping table. - **Type** @@ -14,12 +38,14 @@ Changes object based on a source and a mapping table. - **Details** - The mapped function is ran in a new reactive scope that is destroyed when - the source changes and maps to a different function. + Returns a source holding an instance of the currently shown component. - ::: warning - Mapped functions cannot yield. - ::: + When the input source changes, the new value will be used to lookup a given + mapping table to get a component, which will be ran under a new reactive + scope. If the input source changes, the reactive scope the component was + created in will be destroyed, and a new component created under a new + reactive scope. If no component is found for an input value, the switch will + output `nil`. - **Example** @@ -51,24 +77,26 @@ Maps each index in a table source to an object. - **Details** + Returns a source holding an array of instances currently shown. + + When the input source changes, each *index* in the new table is compared with + the last input table. + + - For any new index, the `transform` function is ran under a new reactive + scope to produce a new instance. + - For any removed index, the reactive scope for that index is destroyed. + - Unchanged indexes are untouched. + The transform function is called only ever *once* for each index in the - source table. The first argument is a source containing the index's value - and the second argument is just the index. + source table. - Anytime a new index is added, the transform function will be called again - for that new index. + 1. First argument is a *source containing the index's value*. + 2. Second argument is the *index itself*. - Anytime an existing index value changes, the transform function is not rerun, - instead the source value for that index will update, causing anything + Anytime an existing index's value changes, the transform function is not + rerun, instead the source value for that index will update, causing anything depending on it to update too. - Returns a state containing an array of all objects returned by the - transform. - - ::: warning - `transform()` cannot yield. - ::: - - **Example** The intended purpose of this function is to map each index in a table to @@ -85,14 +113,12 @@ Maps each index in a table source to an object. local displays = indexes(items, function(item, i) return ItemDisplay { Name = function() - return item().name + return i .. ": " .. item().name end, Image = function() return "rbxassetid://" .. item().icon end, - - LayoutOrder = i } end) ``` @@ -111,28 +137,31 @@ Maps each value in a table source to an object. - **Details** - The transform function is called only ever *once* for each value in the - source table. The first argument is the index's value and - the second argument is a source containing the index. + Returns a source holding an array of instances currently shown. - Anytime a new value is added, the transform function will be called again - for that new value. + When the input source changes, each *value* in the new table is compared with + the last input table. Similar to `indexes()` but for values instead of indexes. + + - For any new value, the `transform` function is ran under a new reactive + scope to produce a new instance. + - For any removed value, the reactive scope for that value is destroyed. + - Unchanged values are untouched. + + The transform function is only ever called *once* for each value in the + source table. + + 1. First argument is the *value itself*. + 2. Second argument is a *source containing the value's index*. Anytime an existing value's index changes, the transform function is not rerun, instead the source index for that value will update, causing anything depending on it to update too. - Returns a state containing an array of all objects returned by the - transform. - ::: warning - `transform()` cannot yield. - ::: - - ::: warning - Having primitive values in the source table can cause unexpected behavior, - as duplicate primitives can result in multiple index sources being bound - to the same UI element. + Having primitive values in the input source table can cause unexpected + behavior, as duplicate values can result in multiple tranforms being ran for + a single value, meaning there can be multiple source indexes bound to the + same UI element. Strict mode has checks for this. ::: - **Example** @@ -150,11 +179,11 @@ Maps each value in a table source to an object. local displays = values(items, function(item, i) return ItemDisplay { - Name = item.Name + Name = function() + return i() .. ": " .. item.Name + end Image = "rbxassetid://" .. item.icon, - - LayoutOrder = i } end) ``` @@ -181,6 +210,9 @@ Maps each value in a table source to an object. In most cases, both functions will appear to have the same behavior. The main difference is performance, picking the right function to use can - result in less property updates and less re-renders. + result in less property updates and less re-renders. One case to note is + that `values()` works nicely when animating re-ordering of instances, since + the value is not destroyed when indexes are changed, and the source index + can easily be put through a spring. -------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 22adba5..9219b29 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -24,20 +24,6 @@ Runs a callback anytime a reactive scope is re-ran. end) ``` - ```lua - local data = source(1) - - derive(function() - local label = create "TextLabel" { Text = data() } - - cleanup(function() - label:Destroy() - end) - - return label - end) - ``` - ## untrack() Runs a given function where any sources read will not track its reactive scope. @@ -70,4 +56,14 @@ Runs a given function where any sources read will not track its reactive scope. print(sum()) -- 2 ``` +## read() + +Utility used to read a value that is either a primitive or a source. + +- **Type** + + ```lua + function read(value: T | () -> T): T + ``` + -------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 91be656..35de4d8 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -6,6 +6,9 @@ Strict mode is library-wide and can get set by doing: vide.strict = true ``` +It is automatically enabled when Vide is first required and not running in O2 +optimization level. + Strict mode is designed to help the development process by adding safety checks and identifying improper usage. @@ -15,8 +18,9 @@ Currently, strict mode will: 2. Run effects twice when a source updates. 3. Throw an error if yields occur where they are not allowed. 4. Checks for `indexes()` and `values()` returning primitive values. -5. Checks for duplicate nested properties at same depth. -6. Better error reporting and stack traces + creation traces of property bindings. +5. Checks for `values()` input having duplicate values. +6. Checks for duplicate nested properties at same depth. +7. Better error reporting and stack traces + creation traces of property bindings. By rerunning sources and effects, any side-effects are made more apparent. This also helps ensure that cleanups are being handled correctly. @@ -29,4 +33,5 @@ recording and better emitting stack traces where errors occur, particularly when binding properties to sources. It is recommend to develop UI with strict mode and to disable it when pushing to -production. +production. In Roblox, production code compiles at O2 by default, so you don't +need to worry about disabling strict mode unless you have manually enabled it. diff --git a/docs/tut/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md similarity index 100% rename from docs/tut/reactive-scoping.md rename to docs/tut/advanced/reactive-scoping.md diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md new file mode 100644 index 0000000..8628e45 --- /dev/null +++ b/docs/tut/control-flow/2-show.md @@ -0,0 +1 @@ +# show() diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md new file mode 100644 index 0000000..647835a --- /dev/null +++ b/docs/tut/control-flow/3-switch.md @@ -0,0 +1 @@ +# switch() diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md new file mode 100644 index 0000000..aad2c90 --- /dev/null +++ b/docs/tut/control-flow/4-indexes.md @@ -0,0 +1 @@ +# indexes() diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md new file mode 100644 index 0000000..8909904 --- /dev/null +++ b/docs/tut/control-flow/5-values.md @@ -0,0 +1 @@ +# values() diff --git a/docs/tut/control-flow/indexes.md b/docs/tut/control-flow/indexes.md deleted file mode 100644 index 70864e7..0000000 --- a/docs/tut/control-flow/indexes.md +++ /dev/null @@ -1,66 +0,0 @@ -# Control Flow - -Vide has specific functions for dealing with sources that store a table value. - -Often, you will have a table of values that will be displayed in a similar -manner. Rather than manually looping over each value to generate a corresponding -UI element, Vide provides functions `indexes()` and `values()` to do this for -you. - -`indexes()` maps each *index* in a table to a UI element. - -```lua -local names = source { "a", "b", "c" } - -local elements = indexes(names, function(name, i) - return create "TextLabel" { - Text = function() - return "Name: " .. name() - end, - - LayoutOrder = i - } -end) -``` - -What happens here is the given callback is only ever ran *once* for each index -in the table. The callback receives two arguments, a *source* containing the -index's value and then the index itself. - -Anytime the value at a corresponding index changes, the source for that index -value is updated, causing the UI element depending on it to update too. - -`values()` behaves similarly, except it maps each *value* in a table to a UI -element. - -```lua -type Item = { - Name: string, - Icon: number -} - -local items = source({} :: Array) - -local elements = values(items, function(item, i) - return create "ImageLabel" { - Image = "rbxassetid://" .. item.Icon, - LayoutOrder = i - } -end) -``` - -The callback is again only ever ran *once* for each value in the table. The -callback receives two arguments, a value in the table and then a *source* -containing the value's corresponding index. - -Any time a value in a table changes index, the source for that value is updated, -causing the UI element position to change. - -In certain cases `values()` can cause less recalculation and rerenders than -`indexes()` like when items are re-arranged and shifted within a table. - -It is important that each value in a table is unique when using `values()`, -and for this reason always using `indexes()` if a table contains primitive -values. - -Both `indexes()` and `values()` return an array of all mapped UI elements. diff --git a/docs/tut/control-flow/switch.md b/docs/tut/control-flow/switch.md deleted file mode 100644 index 70864e7..0000000 --- a/docs/tut/control-flow/switch.md +++ /dev/null @@ -1,66 +0,0 @@ -# Control Flow - -Vide has specific functions for dealing with sources that store a table value. - -Often, you will have a table of values that will be displayed in a similar -manner. Rather than manually looping over each value to generate a corresponding -UI element, Vide provides functions `indexes()` and `values()` to do this for -you. - -`indexes()` maps each *index* in a table to a UI element. - -```lua -local names = source { "a", "b", "c" } - -local elements = indexes(names, function(name, i) - return create "TextLabel" { - Text = function() - return "Name: " .. name() - end, - - LayoutOrder = i - } -end) -``` - -What happens here is the given callback is only ever ran *once* for each index -in the table. The callback receives two arguments, a *source* containing the -index's value and then the index itself. - -Anytime the value at a corresponding index changes, the source for that index -value is updated, causing the UI element depending on it to update too. - -`values()` behaves similarly, except it maps each *value* in a table to a UI -element. - -```lua -type Item = { - Name: string, - Icon: number -} - -local items = source({} :: Array) - -local elements = values(items, function(item, i) - return create "ImageLabel" { - Image = "rbxassetid://" .. item.Icon, - LayoutOrder = i - } -end) -``` - -The callback is again only ever ran *once* for each value in the table. The -callback receives two arguments, a value in the table and then a *source* -containing the value's corresponding index. - -Any time a value in a table changes index, the source for that value is updated, -causing the UI element position to change. - -In certain cases `values()` can cause less recalculation and rerenders than -`indexes()` like when items are re-arranged and shifted within a table. - -It is important that each value in a table is unique when using `values()`, -and for this reason always using `indexes()` if a table contains primitive -values. - -Both `indexes()` and `values()` return an array of all mapped UI elements. diff --git a/docs/tut/control-flow/values.md b/docs/tut/control-flow/values.md deleted file mode 100644 index 70864e7..0000000 --- a/docs/tut/control-flow/values.md +++ /dev/null @@ -1,66 +0,0 @@ -# Control Flow - -Vide has specific functions for dealing with sources that store a table value. - -Often, you will have a table of values that will be displayed in a similar -manner. Rather than manually looping over each value to generate a corresponding -UI element, Vide provides functions `indexes()` and `values()` to do this for -you. - -`indexes()` maps each *index* in a table to a UI element. - -```lua -local names = source { "a", "b", "c" } - -local elements = indexes(names, function(name, i) - return create "TextLabel" { - Text = function() - return "Name: " .. name() - end, - - LayoutOrder = i - } -end) -``` - -What happens here is the given callback is only ever ran *once* for each index -in the table. The callback receives two arguments, a *source* containing the -index's value and then the index itself. - -Anytime the value at a corresponding index changes, the source for that index -value is updated, causing the UI element depending on it to update too. - -`values()` behaves similarly, except it maps each *value* in a table to a UI -element. - -```lua -type Item = { - Name: string, - Icon: number -} - -local items = source({} :: Array) - -local elements = values(items, function(item, i) - return create "ImageLabel" { - Image = "rbxassetid://" .. item.Icon, - LayoutOrder = i - } -end) -``` - -The callback is again only ever ran *once* for each value in the table. The -callback receives two arguments, a value in the table and then a *source* -containing the value's corresponding index. - -Any time a value in a table changes index, the source for that value is updated, -causing the UI element position to change. - -In certain cases `values()` can cause less recalculation and rerenders than -`indexes()` like when items are re-arranged and shifted within a table. - -It is important that each value in a table is unique when using `values()`, -and for this reason always using `indexes()` if a table contains primitive -values. - -Both `indexes()` and `values()` return an array of all mapped UI elements. diff --git a/docs/tut/crash-course/12-strict-mode.md b/docs/tut/crash-course/12-strict-mode.md index c66af04..3cb09e6 100644 --- a/docs/tut/crash-course/12-strict-mode.md +++ b/docs/tut/crash-course/12-strict-mode.md @@ -5,5 +5,27 @@ be set with `vide.strict = true` once when you first require Vide. Strict mode will add extra safety checks and emit better error traces, particularly when errors occur in property bindings. +Strict mode will run derived sources and effects twice each time they update. +This is to help identify improper cleanup of side-effects and ensure that pure +computations are actually pure. + +```lua +local source = vide.source +local effect = vide.effect + +vide.strict = true + +local count = source(0) + +local ran = 0 +effect(function() + ran += 1 +end) + +print(ran) -- 2 +count(1) +print(ran) -- 4 +``` + A full list of what strict mode will do can be found [here](../../api/strict-mode). diff --git a/docs/tut/crash-course/9-control-flow.md b/docs/tut/crash-course/9-control-flow.md index fdde0ae..8e19fe2 100644 --- a/docs/tut/crash-course/9-control-flow.md +++ b/docs/tut/crash-course/9-control-flow.md @@ -1,56 +1,105 @@ # 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, +resulting from source updates. 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 functions return new sources, which hold 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. +Control flow functions are special, because they run their components in a new +reactive scope, which can be destroyed independently of the reactive scope that +called the control flow function itself. This means that parts of your app can +be independently created then destroyed and cleaned. + +## show() + +The most basic control flow function is `show()`, which is used to conditionally +show a component. + +```lua +local source = vide.source +local show = vide.show + +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + return create "Frame" { + show(function() return not joined() end, JoinButton) + } +end +``` + +This will make a button to join if you have not joined already. + +You can also pass a third argument, a fallback to show if the condition is falsey. + +```lua +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + local function LeaveButton() + return Button { + Activated = function() joined(false) end + } + end + + return create "Frame" { + show(joined, LeaveButton, JoinButton) + } +end +``` + +## switch() + +Similar to `show()`, `switch()`, also condtionally displays one instance at a +time. It is more flexible since it can show one of many components, based on a +table used to map a source value to a component. ```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 function JoinMenu() + local joined = source(false) -local loggedIn = source(false) + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end -local function LoginMenu() - return Frame { - switch(loggedIn) { - [true] = function() - return ToggleButton { Text = "Log out", Toggle = loggedIn } - end, + local function LeaveButton() + return Button { + Activated = function() joined(false) end + } + end - [false] = function() - return ToggleButton { Text = "Log in", Toggle = loggedIn } - end + return create "Frame" { + switch(joined) { + [true] = LeaveButton, + [false] = JoinButton } } 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. +Above is an example of using a switch to create a join menu. Each time +`joined` toggles, the current button will be destroyed, and a new button +created, which the text to represent the current action, to join or leave. The callbacks given to control flow functions are ran in a new reactive-scope, so any cleanups registered will be ran when the input is changed and a new From 5735d8e3f06a7b5171d2286592dd468d4f3f6a3a Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 21:56:25 +0100 Subject: [PATCH 15/56] Update docs --- CHANGELOG.md | 2 +- docs/tut/crash-course/9-control-flow.md | 65 ++++++++++++++----------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d7d4d..2127a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [0.1.0] - 0000-00-00 +## [0.1.0] - 2023-09-20 - Initial release diff --git a/docs/tut/crash-course/9-control-flow.md b/docs/tut/crash-course/9-control-flow.md index 8e19fe2..afeabfc 100644 --- a/docs/tut/crash-course/9-control-flow.md +++ b/docs/tut/crash-course/9-control-flow.md @@ -97,52 +97,61 @@ local function JoinMenu() end ``` -Above is an example of using a switch to create a join menu. Each time -`joined` toggles, the current button will be destroyed, and a new button -created, which the text to represent the current action, to join or leave. +This example is equivalent to the previous one. -The callbacks given to control flow functions are ran in a new reactive-scope, -so any cleanups registered will be ran when the input is changed and a new -output is created. +The switch can map any value to any component. -Another control flow function, `indexes()`, is used to create elements from an -input table. +```lua +type ActiveMenu = "none" | "inventory" | "shop" | "settings" + +local menu = source "none" + +switch(menu) { + inventory = InventoryMenu, + shop = ShopMenu. + settings = SettingsMenu +} +``` + +## indexes() 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. +UI element, `indexes()` allows you to create an instance for each table index, +to display the value at that index. ```lua local todoList = { - "Finish the crash course", - "Star vide's GitHub" + "finish the crash course", + "star vide's GitHub" } -local elements = indexes(todoList, function(todo, i) - return create "TextLabel" { - Text = function() - return i .. ": " .. todo() - end, +local function TodoList(props: { list: () -> Array }) + return create "Frame" { + create "UIListLayout" {}, - LayoutOrder = i - } -end) + indexes(todoList, function(todo, i) + return create "TextLabel" { + Text = function() + return i .. ": " .. todo() + end, -mount(function() - return create "ScreenGui" { - create "UIListLayout" {}, elements + LayoutOrder = i + } + end) } -end, game.StarterGui) +end + +TodoList { list = todoList } ``` 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. +given source for that index is updated. An element is only destroyed if the value of an index is set to `nil`. + +Together, these control flow functions cover the majority of cases where you +need to dynamically create and destroy parts of your UI. From d671fb9cd95e8ef4496a670492904ff9975215db Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 22:24:18 +0100 Subject: [PATCH 16/56] Update logo Thank you @littensy for making the logo --- docs/.vitepress/config.ts | 9 ++++----- docs/public/logo.svg | 41 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ae4c9e5..18e36cc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -16,7 +16,6 @@ export default defineConfig({ { text: "Home", link: "/" }, { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, { text: "API", link: "/api/reactivity-core"}, - { text: "GitHub", link: "https://github.com/centau/vide" } ], sidebar: { @@ -59,10 +58,10 @@ export default defineConfig({ ] } ], - } + }, - // socialLinks: [ - // { icon: "github", link: "https://github.com/centau/vide" } - // ] + socialLinks: [ + { icon: "github", link: "https://github.com/centau/vide" } + ] } }) diff --git a/docs/public/logo.svg b/docs/public/logo.svg index 0e64a32..5d8b5f8 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,4 +1,39 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 22d66c9aefb473cb17811cfc2edd057732d9ec17 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 22:29:24 +0100 Subject: [PATCH 17/56] Update README --- README.md | 4 +-- docs/public/full_logo.svg | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 docs/public/full_logo.svg diff --git a/README.md b/README.md index 2901dbe..295c8c5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@
- +
-
- ### ⚠️ This library is in early stages of development with breaking changes being made often. Vide is a reactive UI library. diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg new file mode 100644 index 0000000..64c8da8 --- /dev/null +++ b/docs/public/full_logo.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 92b62a4979ac859e4b8a79815a1f2645e01144d8 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 20 Sep 2023 23:05:26 +0100 Subject: [PATCH 18/56] Update SVG viewboxes --- README.md | 8 ++------ docs/public/full_logo.svg | 2 +- docs/public/logo.svg | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 295c8c5..8ce077b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@
- +
-### ⚠️ This library is in early stages of development with breaking changes being made often. - -Vide is a reactive UI library. +Vide is a reactive Luau UI library inspired by [Solid](https://www.solidjs.com/). - Fully Luau typecheckable - Declarative and concise syntax. -- Minimal imports. - Reactively driven. ## Getting started @@ -22,7 +19,6 @@ for a quick introduction to the library. ## Code sample ```lua -local vide = require(path_to_vide) local create = vide.create local source = vide.source diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg index 64c8da8..85488e0 100644 --- a/docs/public/full_logo.svg +++ b/docs/public/full_logo.svg @@ -1,4 +1,4 @@ - + diff --git a/docs/public/logo.svg b/docs/public/logo.svg index 5d8b5f8..92b06c1 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,4 +1,4 @@ - + From 8a4aae1be9f284a10a102f59928ffccd65831fdd Mon Sep 17 00:00:00 2001 From: quantix-dev <9021453+quantix-dev@users.noreply.github.com> Date: Wed, 20 Sep 2023 22:51:29 +0100 Subject: [PATCH 19/56] chore: add wally package file --- wally.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 wally.toml diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..f118dc8 --- /dev/null +++ b/wally.toml @@ -0,0 +1,19 @@ +[package] +name = "centau/vide" +description = "A reactive Luau library for creating UI. " +license = "MIT" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +include = ["default.project.json", "LICENSE", "src"] +exclude = [ + ".github", + "docs", + "test", + ".gitattributes", + ".gitignore", + ".luaurc", + "CHANGELOG.md", + "README.md", + "todo.md" +] \ No newline at end of file From 4b877ce8ff3e8b09529c9b0d231009ef7afe2eb1 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:29:14 +0100 Subject: [PATCH 20/56] Update docs --- docs/api/reactivity-core.md | 31 +++++++------------ docs/api/reactivity-utility.md | 8 +++-- docs/tut/crash-course/5-effect.md | 2 +- docs/tut/crash-course/6-stateful-component.md | 4 +-- 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 0e4a5d0..133bc4a 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -2,6 +2,10 @@
+:::warning +Yielding is not allowed in any reactive scope. Strict mode can check for this. +::: + ## root() Creates and runs a function in a new reactive scope. @@ -14,18 +18,14 @@ Creates and runs a function in a new reactive scope. - **Details** + Returns the result of the given function. + Creates a new root reactive scope, where creation and derivations of sources can be tracked and properly disposed of. - Returns the result of the given function. - A function to destroy the root is passed into the callback, which will run any cleanups and allow derived sources created to garbage collect. - ::: warning - `fn()` cannot yield. - ::: - ## source() Creates a new source with the given value. @@ -44,6 +44,8 @@ Creates a new source with the given value. Reading from the source from within a reactive scope will cause changes to that source to be tracked and anything depending on it to update. + Sources can be created outside of reactive scopes. + - **Example** ```lua @@ -56,7 +58,7 @@ Creates a new source with the given value. ## effect() -Runs a side-effect on source update. +Runs a side-effect in a new reactive scope on source update. - **Type** @@ -66,14 +68,10 @@ Runs a side-effect on source update. - **Details** - The callback is ran immediately. - Any time a source referenced in the callback is changed, the callback will be reran. - ::: warning - `callback()` cannot yield. - ::: + The callback is ran to initially ran on first call to find dependent sources. - **Example** @@ -93,7 +91,7 @@ Runs a side-effect on source update. ## derive() -Derives a new source from existing sources. +Derives a new source in a new reactive scope from existing sources. - **Type** @@ -109,12 +107,7 @@ Derives a new source from existing sources. Anytime its value is recalculated it is also cached, subsequent calls will retun this cached value until it recalculates again. - Takes a callback that is immediately run to determine what sources are being - referenced. - - ::: warning - `source()` cannot yield. - ::: + The callback is ran to initially ran on first call to find dependent sources. - **Example** diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 9219b29..d5292e5 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -2,7 +2,7 @@ ## cleanup() -Runs a callback anytime a reactive scope is re-ran. +Runs a callback anytime a reactive scope is reran or destroyed. - **Type** @@ -26,7 +26,8 @@ Runs a callback anytime a reactive scope is re-ran. ## untrack() -Runs a given function where any sources read will not track its reactive scope. +Runs a given function where any sources read will not be tracked by a reactive +scope. - **Type** @@ -58,7 +59,8 @@ Runs a given function where any sources read will not track its reactive scope. ## read() -Utility used to read a value that is either a primitive or a source. +Utility used to read a value that is either a primitive or a source. Sources +read can still be tracked inside a reactive-scope. - **Type** diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 3891652..c875de9 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,7 +1,7 @@ # Effect Effects are functions that are ran in response to source updates. They are -alled effects because they cause *side-effects* when reacting to source updates. +called effects because they cause *side-effects* when reacting to source updates. Effects are created using `effect()`. diff --git a/docs/tut/crash-course/6-stateful-component.md b/docs/tut/crash-course/6-stateful-component.md index ba167e2..ce9d8d6 100644 --- a/docs/tut/crash-course/6-stateful-component.md +++ b/docs/tut/crash-course/6-stateful-component.md @@ -23,7 +23,7 @@ local function Counter() instance.Text = "count: " .. count() end) - return count + return instance end ``` @@ -49,7 +49,7 @@ local function Counter(props: { count: () -> number }) instance.Text = "count: " .. count() end) - return count + return instance end local count = source(0) From 0e8bfef9050656f2f6425a7fcd8339f98cefedf3 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:40:05 +0100 Subject: [PATCH 21/56] Fix typos in creation docs --- docs/api/creation.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/api/creation.md b/docs/api/creation.md index f5b4c1c..f3f51c8 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -4,7 +4,8 @@ ## mount() -Runs a function and applies its result to a target instance. +Runs a function in a new reactive scope and optionally applies its result to a +target instance. - **Type** @@ -14,7 +15,7 @@ Runs a function and applies its result to a target instance. - **Details** - The result of the function is applies to the target in the same way + The result of the function is applied to a target in the same way properties are using `create()`. The function is ran in a new reactive scope, just like @@ -60,19 +61,16 @@ Creates a new UI element, applying any given properties. - **Property setting rules** - - If a table index is a string: - - If its value is a function then it will either bind that property to - the function or connect it if the property type is a `RBXScriptSignal`. - - If the value is not a function then the property will be set to that - value. - - If a table index is a number: - - If its value is an action then that action will be queued to run after - properties are set. - - If its value is a table then that table will be recursively - processed just like the outer table. - - If its value is a function then it will bind the instances children to - that function. - - If its value is an instance then it will be parented to the instance. + - **index is string:** + - **value is function:** + - **property is event:** connect function as callback + - **property is not event:** create effect to update property + - **value is not function:** set property to value + - **index is number:** + - **value is action:** run action + - **value is table:** recurse table + - **value is functon:** create effect to update children + - **value is instance:** set instance as child - **Example** @@ -138,9 +136,14 @@ instances. ```lua local function changed(property: string, callback: (new) -> ()) return action(function(instance) - instance:GetPropertyChangedSignal("property"):Connect(function() + local con - instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) + + -- disconnect on reactive scope destruction to allow gc of instance + cleanup(function() + con:Disconnect() + end) end) end From 62716a074db2f4837aa9dbbc56ab5a957263d3cd Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 21 Sep 2023 14:58:44 +0100 Subject: [PATCH 22/56] Update benchmarks --- test/benchmark.luau | 81 ++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/test/benchmark.luau b/test/benchmark.luau index ca9c436..f3c39b6 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -16,18 +16,19 @@ local function TITLE(name: string) print(testkit.color.white(name)) end -local N = 2^18 -- 262144 - -local function WRAP_BENCH(name: string, fn: () -> ()) +local function ROOT_BENCH(name: string, fn: () -> ()) vide.root(function(destroy) BENCH(name, fn) return destroy end)() end +local N = 2^18 -- 262144 + + TITLE "sources" -WRAP_BENCH("create source", function() +BENCH("create source", function() local cache = table.create(N) for i = 1, START(N) do @@ -35,7 +36,7 @@ WRAP_BENCH("create source", function() end end) -WRAP_BENCH("get value", function() +BENCH("get value", function() local src = source(1) for i = 1, START(N) do @@ -43,7 +44,7 @@ WRAP_BENCH("get value", function() end end) -WRAP_BENCH("set value", function() +BENCH("set value", function() local src = source(1) for i = 1, START(N) do @@ -51,7 +52,7 @@ WRAP_BENCH("set value", function() end end) -WRAP_BENCH("derive 1 source", function() +ROOT_BENCH("derive 1 source", function() local cache = table.create(N) local src = source(1) @@ -62,7 +63,7 @@ WRAP_BENCH("derive 1 source", function() end end) -WRAP_BENCH("derive 4 sources", function() +ROOT_BENCH("derive 4 sources", function() local cache = table.create(N) local src = vide.source(1) local src2 = vide.source(2) @@ -78,7 +79,7 @@ end) TITLE "graphs" -WRAP_BENCH("update 1->1 graph", function() +ROOT_BENCH("update 1->1 graph", function() local src = source(1) local _derived = derive(function() return src() end) @@ -88,7 +89,7 @@ WRAP_BENCH("update 1->1 graph", function() end end) -WRAP_BENCH("update 1->1 graph with cleanup", function() +ROOT_BENCH("update 1->1 graph with cleanup", function() local src = source(1) derive(function() @@ -101,7 +102,7 @@ WRAP_BENCH("update 1->1 graph with cleanup", function() end end) -WRAP_BENCH("update 1->1000 graph", function() +ROOT_BENCH("update 1->1000 graph", function() local src = source(-1) for i = 1, 1000 do @@ -115,7 +116,7 @@ WRAP_BENCH("update 1->1000 graph", function() end end) -WRAP_BENCH("update 1->1->1->1...1000 graph", function() +ROOT_BENCH("update 1->1->1->1...1000 graph", function() local src = source(-1) local last = src @@ -132,7 +133,7 @@ WRAP_BENCH("update 1->1->1->1...1000 graph", function() end) -- todo: repeat with batching -WRAP_BENCH("update 1000->1 graph", function() +ROOT_BENCH("update 1000->1 graph", function() local srcs = {} for i = 1, 1000 do srcs[i] = source(0) @@ -153,7 +154,7 @@ WRAP_BENCH("update 1000->1 graph", function() end) -- todo: optimize, repeat with batching -WRAP_BENCH("update 1000x 1->1 common extern. graph", function() +ROOT_BENCH("update 1000x 1->1 common extern. graph", function() local ext = source(-1) local srcs = {} @@ -173,7 +174,7 @@ end) TITLE "property apply" -WRAP_BENCH("apply 0 properties", function() +ROOT_BENCH("apply 0 properties", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -182,7 +183,7 @@ WRAP_BENCH("apply 0 properties", function() end end) -WRAP_BENCH("apply 8 properties", function() +ROOT_BENCH("apply 8 properties", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -200,7 +201,7 @@ WRAP_BENCH("apply 8 properties", function() end end) -WRAP_BENCH("bind property", function() +ROOT_BENCH("bind property", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -215,7 +216,7 @@ WRAP_BENCH("bind property", function() return nil end) -WRAP_BENCH("update binding", function() +ROOT_BENCH("update binding", function() local apply = require "src/apply" local instance = create("Frame") {} @@ -232,11 +233,29 @@ WRAP_BENCH("update binding", function() return nil end) +TITLE "switch()" + +ROOT_BENCH("switch()", function() + local M = 2^8 + + local map = {} + for i = 1, M do + map[i] = function() return i end + end + + local input = source(0) + vide.switch(input)(map) + + for i = 1, START(N) do + input(bit32.band(i, M - 1) + 1) -- i % m + 1 + end +end) + TITLE "indexes()" N /= 1024 -WRAP_BENCH("indexes() all new", function() +ROOT_BENCH("indexes() all new", function() local data = {} for i = 1, N do @@ -254,7 +273,7 @@ WRAP_BENCH("indexes() all new", function() return nil end) -WRAP_BENCH("indexes() no change", function() +ROOT_BENCH("indexes() no change", function() local data = {} for i = 1, N do @@ -274,7 +293,7 @@ WRAP_BENCH("indexes() no change", function() return nil end) -WRAP_BENCH("indexes() all change", function() +ROOT_BENCH("indexes() all change", function() local data = {} for i = 1, N do @@ -298,7 +317,7 @@ WRAP_BENCH("indexes() all change", function() src(data) end) -WRAP_BENCH("indexes() all remove", function() +ROOT_BENCH("indexes() all remove", function() local data = {} for i = 1, N do @@ -322,7 +341,7 @@ end) TITLE "values()" -WRAP_BENCH("values() all new", function() +ROOT_BENCH("values() all new", function() local data = {} for i = 1, N do @@ -340,7 +359,7 @@ WRAP_BENCH("values() all new", function() return nil end) -WRAP_BENCH("values() no change", function() +ROOT_BENCH("values() no change", function() local data = {} for i = 1, N do @@ -360,7 +379,7 @@ WRAP_BENCH("values() no change", function() src(data) end) -WRAP_BENCH("values() all change", function() +ROOT_BENCH("values() all change", function() local data = {} for i = 1, N do @@ -385,7 +404,7 @@ WRAP_BENCH("values() all change", function() src(data) end) -WRAP_BENCH("values() all remove", function() +ROOT_BENCH("values() all remove", function() local data = {} for i = 1, N do @@ -409,7 +428,7 @@ N *= 1024 TITLE "cleanup" -WRAP_BENCH("register new cleanup", function() +ROOT_BENCH("register new cleanup", function() local cleanup = cleanup local cleaner = function() end @@ -433,7 +452,7 @@ TITLE "aggregate" do -- the purpose of the two following benchmarks is to measure the overhead of -- aggregate construction - WRAP_BENCH("set explicit mock vector2", function() + ROOT_BENCH("set explicit mock vector2", function() local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -448,7 +467,7 @@ do end end) - WRAP_BENCH("set aggregate mock vector2", function() + ROOT_BENCH("set aggregate mock vector2", function() local apply = require "src/apply" local Vector2 = require "test/mock".Vector2 @@ -467,7 +486,7 @@ end -- innacurate due to no Vector3 in vanilla Luau -- mock vector is 200x slower than native vector --- WRAP_BENCH("spring update", function() +-- ROOT_BENCH("spring update", function() -- local root, source, spring = vide.root, vide.source, vide.spring -- local src = source(0) @@ -487,7 +506,7 @@ end -- N /= 1024 --- WRAP_BENCH("spring step", function() +-- ROOT_BENCH("spring step", function() -- local root, source, spring = vide.root, vide.source, vide.spring -- local src = source(0) From 3c45defeac786131aa7333eb7982abf8d28101d7 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:14:55 +0100 Subject: [PATCH 23/56] Fix typos --- docs/api/reactivity-utility.md | 2 +- docs/tut/crash-course/4-source.md | 2 +- docs/tut/crash-course/6-stateful-component.md | 2 +- docs/tut/crash-course/7-property-binding.md | 5 +++-- docs/tut/crash-course/8-cleanup.md | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index d5292e5..9c7980a 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -60,7 +60,7 @@ scope. ## read() Utility used to read a value that is either a primitive or a source. Sources -read can still be tracked inside a reactive-scope. +read can still be tracked inside a reactive scope. - **Type** diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index 1583c59..b7f7d04 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,4 +1,4 @@ -# Source +# Sources *Sources* in Vide are special objects that store a single value. They are the core of reactivity in Vide. Each source represents a source of data, and they diff --git a/docs/tut/crash-course/6-stateful-component.md b/docs/tut/crash-course/6-stateful-component.md index ce9d8d6..3d16279 100644 --- a/docs/tut/crash-course/6-stateful-component.md +++ b/docs/tut/crash-course/6-stateful-component.md @@ -1,4 +1,4 @@ -# Stateful Component +# Stateful Components A stateful component is a component that stores and displays some data. diff --git a/docs/tut/crash-course/7-property-binding.md b/docs/tut/crash-course/7-property-binding.md index b3840cc..9488fba 100644 --- a/docs/tut/crash-course/7-property-binding.md +++ b/docs/tut/crash-course/7-property-binding.md @@ -3,7 +3,8 @@ Explicitly creating effects to update properties can become verbose when there are a lot of properties to update. Vide provides a way to *implicitly* create an effect to update properties on source update. This is also known as -*property binding*, as a property is binded to reflect some data. +*property binding*, since changes to a source will automatically update the +property. ```lua local create = vide.create @@ -29,7 +30,7 @@ Instead of explicitly creating an effect, assigning a (non-event) property a function will implicitly create a side-effect to update that property anytime a dependent source is updated. -Just like effects, the function is ran immediately in a reactive-scope to set +Just like effects, the function is ran immediately in a reactive scope to set the property initially and determine what sources are being depended on. This allows you as the programmer to not need to manually update UI as the state diff --git a/docs/tut/crash-course/8-cleanup.md b/docs/tut/crash-course/8-cleanup.md index 3a04463..16d611d 100644 --- a/docs/tut/crash-course/8-cleanup.md +++ b/docs/tut/crash-course/8-cleanup.md @@ -43,6 +43,6 @@ covered next. On a related note: the reason why `mount()` is used to create your app, is so that any top-level components that need to be cleaned up, can be cleaned up -when the app is later unmounted, since `mount()` runs in a reactive-scope to +when the app is later unmounted, since `mount()` runs in a reactive scope to track `cleanup()` calls. Vide's entire reactive system is independent from the life-time of instances; instances are just a side-effect of the reactive system. From e9742e88fdbd46a0e5c52afd8eeb77b2cb426137 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:15:32 +0100 Subject: [PATCH 24/56] Add mermaid diagrams to docs --- docs/.vitepress/config.ts | 5 +- docs/package.json | 3 +- docs/tut/advanced/reactive-scoping.md | 116 +++++++++++++++++++++++--- docs/tut/crash-course/5-effect.md | 25 +++++- 4 files changed, 131 insertions(+), 18 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 18e36cc..dec9146 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,7 +1,8 @@ -import { defineConfig } from "vitepress" +//import { defineConfig } from "vitepress" +import { withMermaid } from "vitepress-plugin-mermaid"; // https://vitepress.dev/reference/site-config -export default defineConfig({ +export default withMermaid({ title: "Vide", titleTemplate: ":title - A reactive UI library for Luau", description: "A reactive UI library for Luau.", diff --git a/docs/package.json b/docs/package.json index 921ee39..e156bea 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { - "vitepress": "^1.0.0-rc.4" + "vitepress": "^1.0.0-rc.4", + "vitepress-plugin-mermaid": "^2.0.14" } } diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md index 5b983ab..99a09f4 100644 --- a/docs/tut/advanced/reactive-scoping.md +++ b/docs/tut/advanced/reactive-scoping.md @@ -1,13 +1,54 @@ # Reactive Scoping This is a brief document designed to give the user more insight into how Vide's -reactive graph works. +reactive system works. + +Vide's reactivity can be pictured as a graph, where each source, derived source, +and effect is a node on that graph. For example: + +```lua + +root(function() + local forename = source "quan" + local surname = source "xi" + + local name = derive(function() + return forename() .. " " .. surname() + end) + + effect(function() + print("new name: " .. name()) + end) +end) +``` 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. +This code will produce a graph that looks like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +flowchart + subgraph root + forename & surname --> name + name --> effect + end +``` + 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 @@ -30,32 +71,85 @@ 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. +Nodes created by `source()` can actually exist outside of root nodes, since +they do not have direct side-effects or cleanups, they do not have to be +explicitly destroyed. + 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 function Counter(props: { text: string }) 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 } + return create "TextLabel" { + Text = function() + return props.text() .. ": " .. count() + end + } 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. +```lua +root(function() + local counters = { "A", "B" } -> todo: add graphics + indexes(counters, function(name) + return Counter { text = name } + end) +end) +``` + +This code produces a graph like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +flowchart LR + subgraph root + counters --> indexes + end + + subgraph root1[subroot 1] + n1[name] --> p1[prop binding] + end + + subgraph root2[subroot 2] + n2[name] --> p2[prop binding] + end + + indexes .-> root1 & root2 +``` + +This shows how the `indexes()` control flow function creates and manages new +root scopes. The function creates an effect seen as `indexes` in the graph, +which manages the new roots `subroot 1` and `subroot 2`, as well as the sources +`name` for which one exists for each index value in the input table. + +When the input table changes, `indexes()` can automatically destroy and create +subroots based on the changed indexes. Destroyed nodes run any cleanups made, in +this case it is the cleanups to disconnect the counters connection. The same +applies to all other control flow functions. + +Whenever the root reactive scope is destroyed, all its children, `counters` and +`indexes` will be destroyed too, which means that `indexes` children, the +subroots, will also be destroyed. Everything is nicely cleaned up. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index c875de9..f75d21a 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,4 +1,4 @@ -# Effect +# Effects Effects are functions that are ran in response to source updates. They are called effects because they cause *side-effects* when reacting to source updates. @@ -20,9 +20,9 @@ count(1) -- "count: 1" printed ``` -The callback given to `effect()` is ran in a *reactive-scope*. Any source read +The callback given to `effect()` is ran in a *reactive scope*. Any source read from inside a reactive scope will be tracked, so that if any of those sources -update, the effect will be re-ran too. +update, the effect will be reran too. The callback is first ran immediately inside the `effect()` call to initially figure out what sources are being used. @@ -56,9 +56,26 @@ All observable changes to the user are considered to be side-effects of pure computations. Sources, derived sources, and effects form what is called a *reactive graph*. -In the above example a graph `count -> doubled -> effect` is formed. Anywhere +In the above example, the following graph is formed. Anywhere an update occures, everything further down the graph is updated. +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +flowchart LR + count --> doubled --> effect +``` + You should not update other sources using an effect. Improper usage can lead to a cyclic loop in the graph, causing an infinite loop when it tries to update. Sources should be derived instead. From 15026b5ad361e3b3ff83caae3893186959175966 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:57:14 +0100 Subject: [PATCH 25/56] Update todo --- todo.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/todo.md b/todo.md index 5b9fc47..a3f26a8 100644 --- a/todo.md +++ b/todo.md @@ -8,6 +8,27 @@ - Portal - batch - optimize `indexes()` double-diffing -- define behavior of deriving a source within a derived source -- define behavior of node destruction under a root that has a child in another root - improve crash course, some sections feel like information dumps +- reactive scopes + - define behavior of creating a reactive scope within a non-root reactive scope + - i.e. an effect within an effect + - error on attempt for now + - define behavior of destruction of a node with a child, where the node and + its child are in separate, non-nested root reactive scopes. + - should destruction of the parent also destroy the child? + - or should destruction of the parent silently disconnect the child, without + invoking the child's cleanups? + + ```mermaid + graph LR + + subgraph root1 + parent + end + + subgraph root2 + child + end + + parent --> child + ``` From c0c2166edf6849dbae775dc96a259555e50abf5e Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 24 Sep 2023 01:49:16 +0100 Subject: [PATCH 26/56] Update testkit --- test/testkit.luau | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/testkit.luau b/test/testkit.luau index 544c7cc..31876eb 100644 --- a/test/testkit.luau +++ b/test/testkit.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- testkit.luau --- v0.7.1 +-- v0.7.2 -------------------------------------------------------------------------------- local color = { @@ -323,7 +323,7 @@ end local function print2(v: unknown) type Buffer = { n: number, [number]: string } - type Cyclic = { [{}]: true } + type Cyclic = { n: number, [{}]: number } -- overkill concatenationless string buffer local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) @@ -347,16 +347,19 @@ local function print2(v: unknown) else -- is table local tabbed_indent = indent .. TAB - str.n += 1 - if cyclic[value] then - str[str.n] = color.gray "*cyclic reference*" + str.n += 1 + str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`) return else - cyclic[value] = true + cyclic.n += 1 + cyclic[value] = cyclic.n end - str[str.n] = "{\n" + str.n += 3 + str[str.n - 2] = "{ " + str[str.n - 1] = color.gray(tostring(cyclic[value])) + str[str.n - 0] = "\n" local i, v = next(value, nil) while v ~= nil do @@ -393,7 +396,7 @@ local function print2(v: unknown) end local str = { n = 0 } - local cyclic = {} + local cyclic = { n = 0 } tos(v, 0, str, cyclic) print(table.concat(str)) end @@ -455,7 +458,7 @@ return { return BENCH, START end, - print2 = print2, + print = print2, seq = shallow_eq, deq = deep_eq, From 38342b38059021ac28021f830a58b85ac705dcff Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:44:24 +0100 Subject: [PATCH 27/56] Disallow creation of nested tracking scopes --- src/graph.luau | 31 ++--- src/maps.luau | 10 +- src/root.luau | 2 +- src/switch.luau | 2 +- src/untrack.luau | 2 +- test/tests.luau | 318 +++++++++++++++++++++++++++++++++++------------ 6 files changed, 256 insertions(+), 109 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index f911f7b..de6f7e1 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -10,7 +10,7 @@ export type StartNode = { export type Node = { cache: T, - effect: ((T) -> T) | false, + effect: ((T) -> T) | "owner" | "untracked", cleanups: { () -> () } | false, parents: { owner: StartNode?, [number]: StartNode }, [number]: Node @@ -50,8 +50,8 @@ local function get_owning_scope(): Node if not scope then local caller_name = debug.info(2, "n") return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) - elseif scope.effect then - throw("owning scope is not stable; are you trying to derive a new source from within a side-effect?") + elseif scope.effect ~= "owner" then + throw("reactive scope is not an owning scope; new effects cannot be created in side-effects") end return scope end @@ -117,8 +117,6 @@ 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 @@ -168,24 +166,13 @@ local function update_from(node: StartNode, n0: number) -- unparent all children and queue for eval do - local i = 1 - local child = node[i] + local child = node[1] while child do + --assert(child.parents.owner) unparent(child) - n += 1 update_queue[n] = child - - local next_child = node[i] - - -- children who have this parent as an owner will not be unparented - -- if such a child is encountered then skip it - if next_child == child then - i += 1 - next_child = node[i] - end - - child = next_child + child = node[1] end end @@ -194,7 +181,7 @@ local function update_from(node: StartNode, n0: number) -- evaluate all queued children for i = n0 + 1, n do local child = update_queue[i] - if not child.effect then continue end + assert(type(child.effect) == "function") if evaluate_node(child) then update_from(child, n) @@ -212,12 +199,12 @@ end local function track(node: StartNode) local scope = get_scope() - if scope and scope.effect then -- do not track nodes with no effect + if scope and type(scope.effect) == "function" then -- do not track nodes with no effect add_child(node, scope) end end -local function create_node(value: T, effect: false | (T) -> T): Node +local function create_node(value: T, effect: "owner" | (T) -> T): Node return { cache = value, effect = effect, diff --git a/src/maps.luau b/src/maps.luau index 1649039..d18357c 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -30,7 +30,7 @@ end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } local owner = get_owning_scope() - local subowner = create_node(false, false) + local subowner = create_node(false, "owner") set_owner(subowner, owner) local input_cache = {} :: Map @@ -67,7 +67,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, if cv ~= v then if cv == nil then -- create new scope and run transform - local scope = create_node(false, false) + local scope = create_node(false, "owner") scopes[i] = scope :: Node local node = create_start_node(v) @@ -112,6 +112,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, local node = create_node(false :: any, function() return update_children(input()) end) + set_owner(node, owner) evaluate_node(node) @@ -124,7 +125,7 @@ end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } local owner = get_owning_scope() - local subowner = create_node(false, false) + local subowner = create_node(false, "owner") set_owner(subowner, owner) local cur_input_cache_up = {} :: Map @@ -155,7 +156,7 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] if cv == nil then -- create new scope and run transform - local scope = create_node(false, false) + local scope = create_node(false, "owner") scopes[v] = scope :: Node local node = create_start_node(i) @@ -214,6 +215,7 @@ local function values(input: () -> Map, transform: (VI, () -> local node = create_node(false :: any, function() return update_children(input()) end) + set_owner(node, owner) evaluate_node(node) diff --git a/src/root.luau b/src/root.luau index 50a1c1c..5f79bd7 100644 --- a/src/root.luau +++ b/src/root.luau @@ -11,7 +11,7 @@ local destroy = graph.destroy local refs = {} local function root(fn: (destroy: () -> ()) -> T...): T... - local node = create_node(false, false) + local node = create_node(false, "owner") refs[node] = true -- prevent gc of root node diff --git a/src/switch.luau b/src/switch.luau index 421d583..e3efbd6 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -38,7 +38,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () throw("map must map a value to a function") end - local new_scope = create_node(false, false) + local new_scope = create_node(false, "owner") last_scope = new_scope :: Node set_owner(new_scope, owner) diff --git a/src/untrack.luau b/src/untrack.luau index 230674d..32246d2 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -13,7 +13,7 @@ local function untrack(source: () -> T): T -- sources are only tracked if the node in scope has an effect local effect = scope.effect - scope.effect = false + scope.effect = "untracked" local ok, result = pcall(source) diff --git a/test/tests.luau b/test/tests.luau index d9600ef..74a2e3e 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -41,6 +41,7 @@ TEST("graph", function() local get_scope = graph.get_scope local open_scope = graph.open_scope local close_scope = graph.close_scope + local set_owner = graph.set_owner local get_children = graph.get_children local add_cleanup = graph.add_cleanup local destroy = graph.destroy @@ -50,7 +51,7 @@ TEST("graph", function() end local function scope() - return create_node(false, false) + return create_node(false, "owner") end local function cleanup(fn: () -> ()) @@ -75,10 +76,14 @@ TEST("graph", function() end do CASE "rerun linked nodes" + local root = node() local a = node() local b = node() local c = node() + set_owner(b, root) + set_owner(c, root) + local count = 0 local function effect(x) @@ -104,8 +109,15 @@ TEST("graph", function() end do CASE "diamond graph" + -- a -> b -> d + -- -> c + local root = node() local a, b, c, d = node(), node(), node(), node() + set_owner(b, root) + set_owner(c, root) + set_owner(d, root) + local b_cnt, c_cnt, d_cnt = 0, 0, 0 function b.effect(x) b_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end @@ -122,16 +134,52 @@ TEST("graph", function() CHECK(d_cnt == 1) end + do CASE "diamond graph 2" + -- todo: include cached value from parent nodes to confirm update order + -- a -> b -> c -> e + -- -> d + local root = node() + local a, b, c, d, e = node(), node(), node(), node(), node() + + set_owner(b, root) + set_owner(c, root) + set_owner(d, root) + set_owner(e, root) + + local b_cnt, c_cnt, d_cnt, e_cnt = 0, 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 + function e.effect(x) e_cnt += 1; return not x end + + open_scope(b); track(a); close_scope() + open_scope(c); track(b); close_scope() + open_scope(d); track(a); close_scope() + open_scope(e); track(c); track(d); close_scope() + + update(a) + + CHECK(b_cnt == 1) + CHECK(c_cnt == 1) + CHECK(d_cnt == 1) + CHECK(e_cnt == 1) + end + do CASE "duplicate child on rerun" + local root = node() local a, b, c = node(), node(), node() + set_owner(a, root) + set_owner(b, root) + set_owner(c, root) + function c.effect(x) track(a) track(b) return not x end - open_scope(c); assert(c.effect)(NIL); close_scope() + open_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); close_scope() update(a) @@ -168,28 +216,28 @@ TEST("graph", function() items_updated = node() track(items_updated) -- should not - add_child(root, items_updated) + set_owner(items_updated, root) do open_scope(items_updated) track(items) do open_scope(root) - add_child(root, scope1) + set_owner(scope1, root) do open_scope(scope1) clean "scope1" bind1 = node() - add_child(scope1, bind1) + set_owner(bind1, scope1) do open_scope(bind1) clean "bind1" track(selected) close_scope() end close_scope() end - add_child(root, scope2) + set_owner(scope2, root) do open_scope(scope2) clean "scope2" bind2 = node() - add_child(scope2, bind2) + set_owner(bind2, scope2) do open_scope(bind2) clean "bind2" track(selected) @@ -284,6 +332,14 @@ TEST("graph", function() local a, b, c, d, e, f = node(), node(), node(), node(), node(), node() + local root = node() + set_owner(a, root) + set_owner(b, root) + set_owner(c, root) + set_owner(d, root) + set_owner(e, root) + set_owner(f, root) + function b.effect(x) update(d) return not x @@ -418,7 +474,6 @@ TEST("derive()", wrap_root(function() local derive = vide.derive local effect = vide.effect local cleanup = vide.cleanup - local untrack = vide.untrack do CASE "derive new value on source change" local a = source(1) @@ -524,38 +579,43 @@ TEST("derive()", wrap_root(function() CHECK(count == 2) end - do CASE "child with parent as owner not lost" - local num = source(0) + -- do CASE "behavior of effect within an effect" + -- local num = source(1) - local cleaned = {} + -- local ran = table.create(100, 0) + -- local cleaned = table.create(100, 0) - local destroy = vide.mount(function() - local owner = derive(function() - local i = num() + -- local destroy = vide.mount(function() + -- local owner = derive(function() + -- local i = num() - return untrack(function() - return derive(function() - cleanup(function() - cleaned[i] = true - end) - return i - end) - end) - end) + -- return untrack(function() + -- return derive(function() + -- ran[i] += 1 + -- cleanup(function() + -- cleaned[i] += 1 + -- end) + -- return i + -- end) + -- end) + -- end) - local child1 = owner() - num(1) - local child2 = owner() + -- local child1 = owner() + -- num(2) + -- CHECK(cleaned[1] == 1) + -- local child2 = owner() - CHECK(child1() == 0) - CHECK(child2() == 1) - end) + -- CHECK(child1() == 1) + -- CHECK(child2() == 2) + -- end) - destroy() + -- destroy() - CHECK(cleaned[0]) - CHECK(cleaned[1]) - end + -- CHECK(ran[1] == 1) + -- CHECK(ran[2] == 1) + -- CHECK(cleaned[1] == 1) + -- CHECK(cleaned[2] == 1) + -- end do CASE "garbage collection" -- check that `b` does not allow gc of `a` @@ -1070,9 +1130,13 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local output = indexes(input, function(v, i) - count[i] += 1 - return v + local output = vide.root(function() + local output = indexes(input, function(v, i) + count[i] += 1 + return v + end) + + return output end) input { 1, 2, 4 } @@ -1453,11 +1517,8 @@ TEST("spring()", wrap_root(function() end)) TEST("untrack()", wrap_root(function() - local root = vide.root local source = vide.source - local derive = vide.derive local effect = vide.effect - local cleanup = vide.cleanup local untrack = vide.untrack do CASE "does not register dependency" @@ -1506,56 +1567,69 @@ TEST("untrack()", wrap_root(function() CHECK(count == 2) end - do CASE "outer scope" - local outer_count = 0 - local inner_count = 0 - local cleaned_count = 0 + -- do CASE "outer scope" + -- local outer_count = 0 + -- local inner_count = 0 + -- local cleaned_count = 0 - local input = source(0) + -- local input = source(0) - local output, destroy = root(function(destroy) - local output = derive(function() - outer_count += 1 + -- local output, destroy = root(function(destroy) + -- local output = derive(function() + -- outer_count += 1 - return untrack(function() - return derive(function() - inner_count += 1 + -- return untrack(function() + -- return derive(function() + -- inner_count += 1 - cleanup(function() - cleaned_count += 1 - end) + -- cleanup(function() + -- cleaned_count += 1 + -- end) - return tostring(input()) - 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 + + do CASE "cannot create effect within untrack()" + local ok = pcall(function() + effect(function() + untrack(function() + effect(function() end) + return nil 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) + CHECK(not ok) end end)) @@ -1690,6 +1764,90 @@ TEST("read()", wrap_root(function() end end)) +TEST("nested effects cases", function() + -- local vide = require "src/init" + -- local source = vide.source + -- local effect = vide.effect + -- local untrack = vide.untrack + -- local cleanup = vide.cleanup + -- local root = vide.root + + -- local ran = 0 + -- local cleaned = 0 + + -- local function Count() + -- local count = source(0) + + -- effect(function() + -- count() + -- ran += 1 + -- cleanup(function() cleaned += 1 end) + -- end) + + -- return nil + -- end + + -- local function App(destroy) + -- local name = source "a" + + -- effect(function() + -- name() + -- untrack(Count) + -- end) + + -- CHECK(ran == 1) + -- CHECK(cleaned == 0) + + -- name "b" + + -- CHECK(ran == 2) + -- CHECK(cleaned == 1) + -- print(cleaned) + -- end + + -- root(App) + + local vide = require "src/init" + local source = vide.source + local effect = vide.effect + local untrack = vide.untrack + local cleanup = vide.cleanup + local root = vide.root + + local ran = 0 + local cleaned = 0 + + local function Count() + local count = source(0) + + effect(function() + count() + ran += 1 + cleanup(function() cleaned += 1 end) + end) + + return nil + end + + local function App(destroy) + local name = source "a" + + effect(function() + name() + untrack(Count) + end) + + CHECK(ran == 1) + CHECK(cleaned == 0) + end + + local ok = pcall(function() + root(App) + end) + + CHECK(not ok) +end) + vide.strict = true TEST("strict", wrap_root(function() From c68d0a6c18cb93e14afc125f6b637fab8b57916c Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Mon, 25 Sep 2023 23:37:51 +0100 Subject: [PATCH 28/56] Allow creation of nested tracking scopes It turns out that handling destruction of a nested tracking scope was not as difficult as I thought and didn't need much changes --- CHANGELOG.md | 4 ++ src/graph.luau | 69 +++++++++++++----- src/maps.luau | 8 +-- src/root.luau | 2 +- src/switch.luau | 2 +- src/untrack.luau | 2 +- test/tests.luau | 178 ++++++++++++++++++----------------------------- 7 files changed, 128 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2127a86..c42c5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Changed + +- Reactive scopes created within reactive scopes are now destroyed on rerun. + --- ## [0.1.0] - 2023-09-20 diff --git a/src/graph.luau b/src/graph.luau index de6f7e1..6917462 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -10,10 +10,14 @@ export type StartNode = { export type Node = { cache: T, - effect: ((T) -> T) | "owner" | "untracked", + effect: ((T) -> T) | false, cleanups: { () -> () } | false, - parents: { owner: StartNode?, [number]: StartNode }, - [number]: Node + + owned: { Node } | false, + owner: Node | false, + + parents: { StartNode }, + [number]: Node -- children } -- reactive scope stack @@ -50,7 +54,7 @@ local function get_owning_scope(): Node if not scope then local caller_name = debug.info(2, "n") return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) - elseif scope.effect ~= "owner" then + elseif scope.effect then throw("reactive scope is not an owning scope; new effects cannot be created in side-effects") end return scope @@ -62,8 +66,12 @@ local function add_child(parent: StartNode, child: Node) end local function set_owner(node: Node, owner: Node) - node.parents.owner = owner - table.insert(owner, node) + node.owner = owner + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end end local function open_scope(node: Node) @@ -96,18 +104,29 @@ local function run_cleanups(node: Node) end end +local function find_and_swap_pop(t: { T }, v: T) + local idx = table.find(t, v) + assert(idx, "value not found") + local n = #t + t[idx] = t[n] + t[n] = nil +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 + find_and_swap_pop(parent, child) +end + +local function remove_owner(node: Node) + local owner = node.owner :: Node + if node.owner and owner.owned then + find_and_swap_pop(owner.owned, node) + end end local function unparent(node: Node) local parents = node.parents - for i, parent in ipairs(parents) do + for i, parent in next, parents do remove_child(parent, node) parents[i] = nil end @@ -116,15 +135,21 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) + remove_owner(node) - if node.parents.owner then - remove_child(node.parents.owner, node) - node.parents.owner = nil + if node.owned then + local owned = node.owned + while owned[1] do destroy(owned[1]) end end - while node[1] do destroy(node[1]) end end +local function destroy_owned(node: Node) + if node.owned then + while node.owned[1] do destroy(node.owned[1]) end + end +end + local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) @@ -132,6 +157,8 @@ local function evaluate_node(node: Node) if flags.strict then run_cleanups(node) + destroy_owned(node) + open_scope(node) local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) @@ -141,7 +168,9 @@ local function evaluate_node(node: Node) if not ok then throw(err :: string) end end - run_cleanups(node) -- todo: move in scope? + run_cleanups(node) + destroy_owned(node) + open_scope(node) local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) @@ -204,11 +233,15 @@ local function track(node: StartNode) end end -local function create_node(value: T, effect: "owner" | (T) -> T): Node +local function create_node(value: T, effect: false | (T) -> T): Node return { cache = value, effect = effect, cleanups = false, + + owner = false, + owned = false, + parents = {}, } end diff --git a/src/maps.luau b/src/maps.luau index d18357c..1034e9a 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -30,7 +30,7 @@ end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } local owner = get_owning_scope() - local subowner = create_node(false, "owner") + local subowner = create_node(false, false) set_owner(subowner, owner) local input_cache = {} :: Map @@ -67,7 +67,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, if cv ~= v then if cv == nil then -- create new scope and run transform - local scope = create_node(false, "owner") + local scope = create_node(false, false) scopes[i] = scope :: Node local node = create_start_node(v) @@ -125,7 +125,7 @@ end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } local owner = get_owning_scope() - local subowner = create_node(false, "owner") + local subowner = create_node(false, false) set_owner(subowner, owner) local cur_input_cache_up = {} :: Map @@ -156,7 +156,7 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] if cv == nil then -- create new scope and run transform - local scope = create_node(false, "owner") + local scope = create_node(false, false) scopes[v] = scope :: Node local node = create_start_node(i) diff --git a/src/root.luau b/src/root.luau index 5f79bd7..50a1c1c 100644 --- a/src/root.luau +++ b/src/root.luau @@ -11,7 +11,7 @@ local destroy = graph.destroy local refs = {} local function root(fn: (destroy: () -> ()) -> T...): T... - local node = create_node(false, "owner") + local node = create_node(false, false) refs[node] = true -- prevent gc of root node diff --git a/src/switch.luau b/src/switch.luau index e3efbd6..421d583 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -38,7 +38,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () throw("map must map a value to a function") end - local new_scope = create_node(false, "owner") + local new_scope = create_node(false, false) last_scope = new_scope :: Node set_owner(new_scope, owner) diff --git a/src/untrack.luau b/src/untrack.luau index 32246d2..230674d 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -13,7 +13,7 @@ local function untrack(source: () -> T): T -- sources are only tracked if the node in scope has an effect local effect = scope.effect - scope.effect = "untracked" + scope.effect = false local ok, result = pcall(source) diff --git a/test/tests.luau b/test/tests.luau index 74a2e3e..08c3cff 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -51,7 +51,7 @@ TEST("graph", function() end local function scope() - return create_node(false, "owner") + return create_node(false, false) end local function cleanup(fn: () -> ()) @@ -257,10 +257,10 @@ TEST("graph", function() do local c = get_children(root) - CHECK(#c == 3) - CHECK(table.find(c, items_updated)) - CHECK(table.find(c, scope1 :: Node)) - CHECK(table.find(c, scope2 :: Node)) + CHECK(#c == 0) + -- CHECK(table.find(c, items_updated)) + -- CHECK(table.find(c, scope1 :: Node)) + -- CHECK(table.find(c, scope2 :: Node)) end do @@ -272,19 +272,19 @@ TEST("graph", function() do local c = get_children(scope1) - CHECK(#c == 1) - CHECK(table.find(c, bind1)) + CHECK(#c == 0) + --CHECK(table.find(c, bind1)) end do local c = get_children(scope2) - CHECK(#c == 1) - CHECK(table.find(c, bind2)) + CHECK(#c == 0) + --CHECK(table.find(c, bind2)) end -- destroy - CHECK(table.find(get_children(root), scope1 :: Node)) + --CHECK(table.find(get_children(root), scope1 :: Node)) destroy(scope1) CHECK(cleaned.scope1) @@ -293,7 +293,7 @@ TEST("graph", function() bind1 = NIL bind2 = NIL gc() - CHECK(#get_children(root) == 2) + CHECK(#get_children(root) == 0) CHECK(#get_children(selected) == 1) end @@ -1519,7 +1519,10 @@ end)) TEST("untrack()", wrap_root(function() local source = vide.source local effect = vide.effect + local derive = vide.derive local untrack = vide.untrack + local cleanup = vide.cleanup + local root = vide.root do CASE "does not register dependency" local a = source(0) @@ -1567,69 +1570,56 @@ TEST("untrack()", wrap_root(function() CHECK(count == 2) end - -- do CASE "outer scope" - -- local outer_count = 0 - -- local inner_count = 0 - -- local cleaned_count = 0 + do CASE "outer scope" + local outer_count = 0 + local inner_count = 0 + local cleaned_count = 0 - -- local input = source(0) + local input = source(0) - -- local output, destroy = root(function(destroy) - -- local output = derive(function() - -- outer_count += 1 + local output, destroy = root(function(destroy) + local output = derive(function() + outer_count += 1 - -- return untrack(function() - -- return derive(function() - -- inner_count += 1 + return untrack(function() + return derive(function() + inner_count += 1 - -- cleanup(function() - -- cleaned_count += 1 - -- end) + 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 - - do CASE "cannot create effect within untrack()" - local ok = pcall(function() - effect(function() - untrack(function() - effect(function() end) - return nil + return tostring(input()) + end) end) end) + + return output, destroy end) - CHECK(not ok) + 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)) @@ -1765,48 +1755,6 @@ TEST("read()", wrap_root(function() end)) TEST("nested effects cases", function() - -- local vide = require "src/init" - -- local source = vide.source - -- local effect = vide.effect - -- local untrack = vide.untrack - -- local cleanup = vide.cleanup - -- local root = vide.root - - -- local ran = 0 - -- local cleaned = 0 - - -- local function Count() - -- local count = source(0) - - -- effect(function() - -- count() - -- ran += 1 - -- cleanup(function() cleaned += 1 end) - -- end) - - -- return nil - -- end - - -- local function App(destroy) - -- local name = source "a" - - -- effect(function() - -- name() - -- untrack(Count) - -- end) - - -- CHECK(ran == 1) - -- CHECK(cleaned == 0) - - -- name "b" - - -- CHECK(ran == 2) - -- CHECK(cleaned == 1) - -- print(cleaned) - -- end - - -- root(App) - local vide = require "src/init" local source = vide.source local effect = vide.effect @@ -1839,13 +1787,19 @@ TEST("nested effects cases", function() CHECK(ran == 1) CHECK(cleaned == 0) + + name "b" + + CHECK(ran == 2) + CHECK(cleaned == 1) + + destroy() + + CHECK(ran == 2) + CHECK(cleaned == 2) end - local ok = pcall(function() - root(App) - end) - - CHECK(not ok) + root(App) end) vide.strict = true From 71bbaa2092f0e76c501b87428c44e71a090135a3 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 26 Sep 2023 13:27:38 +0100 Subject: [PATCH 29/56] Update reactive scoping docs --- docs/tut/advanced/reactive-scoping.md | 149 +++++++++++++++++++++----- test/tests.luau | 73 +++++++++++-- todo.md | 24 +---- 3 files changed, 189 insertions(+), 57 deletions(-) diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md index 99a09f4..f75525e 100644 --- a/docs/tut/advanced/reactive-scoping.md +++ b/docs/tut/advanced/reactive-scoping.md @@ -3,11 +3,34 @@ This is a brief document designed to give the user more insight into how Vide's reactive system works. -Vide's reactivity can be pictured as a graph, where each source, derived source, -and effect is a node on that graph. For example: +## Graph Basics + +Vide's reactivity can be represented as a graph, where each source, derived +source, and effect is a node on that graph. The term "*reactive scope*" is just +an abstraction used to refer to these nodes. Each node is a reactive scope. + +Each node stores a cached value, a side-effect function, cleanup functions, +its parents and children, and its owner and owned. + +Whenever a node is updated it will: + +1. destroy its owned nodes +2. run its cleanups +3. rerun its side-effect and update its cached value +4. if its cached value changes, update its children recursively. + +There is a difference between children nodes and owned nodes: + +- children nodes are updated when a parent is updated. +- owned nodes are destroyed when a parent is updated. +- both children and owned are destroyed when a parent is destroyed. + +Nodes created by `root()` generally have no children, and only tracks owned. +Nodes created by `derive()` generally have no owned, and only tracks children. + +## Basic Example ```lua - root(function() local forename = source "quan" local surname = source "xi" @@ -22,11 +45,6 @@ root(function() end) ``` -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. - This code will produce a graph that looks like so: ```mermaid @@ -49,32 +67,35 @@ flowchart end ``` +Nodes connected by arrows represent parent and children connections. +Nodes within other nodes represent owner and owned connections. + 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 the side-effect for a node is being reran when a node is updated, any +other nodes read within that side-effect are set as parents of the node +currently being reran. As those nodes are read, we know that the current node +depends on them, so any time those nodes are updated, they will update dependent +nodes since they will be stored as children. 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. +When being destroyed, a node's connections (parents and children, owner and +owned) 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 +the root node which will track any node created 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. Nodes created by `source()` can actually exist outside of root nodes, since they do not have direct side-effects or cleanups, they do not have to be explicitly destroyed. +## Control-flow Graph Example + Control flow functions in Vide are special, as they can dynamically create and destroy new root scopes. @@ -120,21 +141,21 @@ This code produces a graph like so: "primaryBorderColor": "#1B1B1F", "lineColor": "#79B8FF", "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryBorderColor": "#fff" } }}%% flowchart LR subgraph root counters --> indexes - end - subgraph root1[subroot 1] - n1[name] --> p1[prop binding] - end + subgraph root1[subroot 1] + n1[name] --> p1[prop binding] + end - subgraph root2[subroot 2] - n2[name] --> p2[prop binding] + subgraph root2[subroot 2] + n2[name] --> p2[prop binding] + end end indexes .-> root1 & root2 @@ -153,3 +174,79 @@ applies to all other control flow functions. Whenever the root reactive scope is destroyed, all its children, `counters` and `indexes` will be destroyed too, which means that `indexes` children, the subroots, will also be destroyed. Everything is nicely cleaned up. + +## Custom Control-flow Example + +Below is a simple example of the `show()` control-flow function. + +Each time `visible` changes, `show()` will destroy the current reactive scope +and rerun its function in a new one. + +```lua +local visible = source(true) +local count = source(0) + +root(function() + show(visible, function() + return create "TextLabel" { Text = count } + end) +end) +``` + +The above code produces a graph like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#fff" + } +}}%% + +flowchart LR + subgraph root + show + + subgraph subroot[show subroot] + p1[prop binding] + end + end + + visible --> show + count --> p1 + show .-> subroot +``` + +This can be recreated without the `show()` control-flow function, with the +following code: + +```lua +local visible = source(true) +local count = source(0) + +root(function() + local output = derive(function() + visible() + + -- untrack so any source read from within this scope + -- will not cause the outer `derive()` call to rerun, + -- we only want `derive()` to rerun when `visible` changes + return untrack(function() + local label = create "TextLabel" {} + + effect(function() + label.Text = count() + end) + + return label + end) + end) +end) +``` + +Both of the above code samples will produce the same visible result. diff --git a/test/tests.luau b/test/tests.luau index 08c3cff..8a11df1 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -981,19 +981,76 @@ TEST("create()", wrap_root(function() end)) TEST("show()", wrap_root(function() - -- uses switch() internally, more extensive testing of scoping not needed + local untrack = vide.untrack + local cleanup = vide.cleanup local source = vide.source + local effect = vide.effect local show = vide.show + local root = vide.root - local value = source("truey" :: unknown) - local function one() return 1 end - local function two() return 2 end + do CASE "main" + -- uses switch() internally, more extensive testing of scoping not needed + local value = source("truey" :: unknown) + local function one() return 1 end + local function two() return 2 end - local output = show(value, one, two) + local output = show(value, one, two) - CHECK(output() == 1) - value(nil) - CHECK(output() == 2) + CHECK(output() == 1) + value(nil) + CHECK(output() == 2) + end + + do CASE "alt" + local visible = vide.source(true) + local count = vide.source(0) + + local outer = 0 + local inner = 0 + local destroyed = 0 + + root(function() + effect(function() + visible() + outer += 1 + + untrack(function() + effect(function() + count() + + inner += 1 + + cleanup(function() + destroyed += 1 + end) + end) + return nil + end) + end) + end) + + CHECK(outer == 1) + CHECK(inner == 1) + CHECK(destroyed == 0) + + count(count() + 1) + CHECK(outer == 1) + CHECK(inner == 2) + CHECK(destroyed == 1) + + visible(false) + CHECK(outer == 2) + CHECK(inner == 3) + CHECK(destroyed == 2) + + count(count() + 1) + CHECK(outer == 2) + CHECK(inner == 4) + CHECK(destroyed == 3) + + + + end end)) TEST("switch()", wrap_root(function() diff --git a/todo.md b/todo.md index a3f26a8..8daf7a7 100644 --- a/todo.md +++ b/todo.md @@ -9,26 +9,4 @@ - batch - optimize `indexes()` double-diffing - improve crash course, some sections feel like information dumps -- reactive scopes - - define behavior of creating a reactive scope within a non-root reactive scope - - i.e. an effect within an effect - - error on attempt for now - - define behavior of destruction of a node with a child, where the node and - its child are in separate, non-nested root reactive scopes. - - should destruction of the parent also destroy the child? - - or should destruction of the parent silently disconnect the child, without - invoking the child's cleanups? - - ```mermaid - graph LR - - subgraph root1 - parent - end - - subgraph root2 - child - end - - parent --> child - ``` +- cleanup source and tests From d1f86a3f9e85d6a114905aa001ffd477e15ecbb9 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:11:33 +0100 Subject: [PATCH 30/56] Allow `untrack()` in non-reactive scopes Closes #17 --- CHANGELOG.md | 1 + src/untrack.luau | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42c5ca..6d6423e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Reactive scopes created within reactive scopes are now destroyed on rerun. +- `untrack()` can be called outside of reactive scopes. --- diff --git a/src/untrack.luau b/src/untrack.luau index 230674d..d9ca120 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -7,21 +7,22 @@ local get_scope = graph.get_scope local function untrack(source: () -> T): T local scope = get_scope() - if not scope then - throw("cannot untrack in non-reactive scope") - end; assert(scope) + + if scope then + -- sources are only tracked if the node in scope has an effect + local effect = scope.effect + scope.effect = false - -- sources are only tracked if the node in scope has an effect - local effect = scope.effect - scope.effect = false + local ok, result = pcall(source) - local ok, result = pcall(source) + scope.effect = effect :: () -> () - scope.effect = effect :: () -> () + if not ok then error(result, 0) end - if not ok then error(result, 0) end - - return result + return result + else + return source() + end end return untrack From 32fa44f4c5ff5a1b1f7a8d4084946a579c5d5fd2 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:34:27 +0100 Subject: [PATCH 31/56] Cleanup helper for instances Closes #19 --- src/cleanup.luau | 30 +++++++++++++++++++++++++++--- src/untrack.luau | 1 - test/tests.luau | 20 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/cleanup.luau b/src/cleanup.luau index 0bfb6c1..803be03 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,19 +1,43 @@ if not game then script = require "test/relative-string" end +local typeof = game and typeof or require "test/mock".typeof :: never local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) local get_scope = graph.get_scope local add_cleanup = graph.add_cleanup -local function cleanup(callback: () -> ()) +local function helper(obj: any) + return + if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end + elseif typeof(obj) == "Instance" then function() obj:Destroy() end + elseif obj.destroy then function() obj:destroy() end + elseif obj.disconnect then function() obj:disconnect() end + elseif obj.Destroy then function() obj:Destroy() end + elseif obj.Disconnect then function() obj:Disconnect() end + else throw("cannot cleanup given object") +end + +local function cleanup(value: unknown) local scope = get_scope() if not scope then throw "cannot cleanup in a non-reactive scope" end; assert(scope) - add_cleanup(scope, callback) + if type(value) == "function" then + add_cleanup(scope, value :: () -> ()) + else + add_cleanup(scope, helper(value)) + end end -return cleanup +type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () } +type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } + +return cleanup :: + ( (callback: () -> ()) -> () ) & + ( (instance: Destroyable) -> () ) & + ( (connection: Disconnectable) -> () ) & + ( (instance: Instance) -> () ) & + ( (connection: RBXScriptConnection) -> () ) diff --git a/src/untrack.luau b/src/untrack.luau index d9ca120..86cdb7b 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,6 +1,5 @@ if not game then script = require "test/relative-string" end -local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node local get_scope = graph.get_scope diff --git a/test/tests.luau b/test/tests.luau index 8a11df1..73ea0b7 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -691,6 +691,7 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() + local root = vide.root local source = vide.source local effect = vide.effect local cleanup = vide.cleanup @@ -749,6 +750,25 @@ TEST("cleanup()", wrap_root(function() src(3) CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) end + + do CASE "cleanup objects" + local ran = {} + + root(function(destroy) + effect(function() + cleanup { disconnect = function() ran.disconnect = true end } + cleanup { Disconnect = function() ran.Disconnect = true end } + cleanup { destroy = function() ran.destroy = true end } + cleanup { Destroy = function() ran.Destroy = true end } + destroy() + end) + end) + + CHECK(ran.disconnect) + CHECK(ran.Disconnect) + CHECK(ran.destroy) + CHECK(ran.Destroy) + end end)) TEST("create()", wrap_root(function() From c3bc53c40a312f67d32894d925392072cd1ea708 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:37:48 +0100 Subject: [PATCH 32/56] Fix typo in docs Fixes #18 --- docs/tut/crash-course/10-property-nesting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tut/crash-course/10-property-nesting.md b/docs/tut/crash-course/10-property-nesting.md index 2aabb11..ea2ae43 100644 --- a/docs/tut/crash-course/10-property-nesting.md +++ b/docs/tut/crash-course/10-property-nesting.md @@ -27,7 +27,7 @@ function Menu(props: { Size: UDim2 }) return Background { - Color = props.COlor, + Color = props.Color, AnchorPoint = props.AnchorPoint, Position = props.Position, Size = props.Size From fd2888afabc7691cb7655c8c0ae219f2eb912c75 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 26 Sep 2023 18:24:30 +0100 Subject: [PATCH 33/56] Update docs --- docs/tut/crash-course/4-source.md | 9 ++++----- docs/tut/crash-course/5-effect.md | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index b7f7d04..4419b72 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -36,8 +36,7 @@ count(1) print(text()) -- "count: 1" ``` -You may be wondering why we are using sources instead of plain variables to do -this. The reason is that Vide has an entire reactive system based on sources. -You can write functions to automatically run each time a source is updated. This -can be to update properties, create new instances, print to the terminal, etc. -How this is done will be covered next. +Derived sources should be pure functions. This is where the same output is +always produced for the same input. As well as making source updates more +predictable, knowing that updates are pure allows Vide to use optimizations such +as caching, to avoid updating derived sources if their inputs are the same. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index f75d21a..406ccd2 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,7 +1,8 @@ # Effects Effects are functions that are ran in response to source updates. They are -called effects because they cause *side-effects* when reacting to source updates. +called effects because they cause *side-effects* when reacting to source updates +which are pure. Effects are created using `effect()`. From d07c705b64c25416b1ff0080cd27b30c2dc2acb7 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:02:09 +0100 Subject: [PATCH 34/56] Fix `switch()` not working in strict mode Fixes #20 --- src/graph.luau | 35 ++++++++++++++--------------------- src/switch.luau | 2 +- test/tests.luau | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index 6917462..bb42fbb 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -23,26 +23,17 @@ export type Node = { -- reactive scope stack local scopes = { n = 0 } :: { [number]: Node, n: number } --- runs a given callback in a context that Luau does not allow yielding in -local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do - local t = { __mode = "kv" } - setmetatable(t, t) +local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) + local thread = coroutine.create(pcall) + local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) - check_for_yield = function(fn, ...: any) - local args = { ... } - - t.__unm = function(_) - fn(unpack(args)) - end - - local ok, err: string? = pcall(function() - local _ = -t - end) - - return ok, if err == "attempt to yield across metamethod/C-call boundary" - or err == "thread is not yieldable" then "yield occured" - else err + assert(resume_ok) + + if coroutine.status(thread) ~= "dead" then + return false, "attempt to yield in reactive scope" end + + return run_ok, result end local function get_scope(): Node? @@ -161,11 +152,13 @@ local function evaluate_node(node: Node) open_scope(node) - local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) + local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) close_scope() - if not ok then throw(err :: string) end + if not ok then throw(new_value :: string) end + + node.cache = new_value :: T end run_cleanups(node) @@ -173,7 +166,7 @@ local function evaluate_node(node: Node) open_scope(node) - local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) close_scope() diff --git a/src/switch.luau b/src/switch.luau index 421d583..1ddc8e6 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -53,7 +53,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () return result end - local node = create_node(nil :: any, update) + local node = create_node(nil :: U?, update) set_owner(node, owner) evaluate_node(node) diff --git a/test/tests.luau b/test/tests.luau index 73ea0b7..eb5581c 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1067,9 +1067,6 @@ TEST("show()", wrap_root(function() CHECK(outer == 2) CHECK(inner == 4) CHECK(destroyed == 3) - - - end end)) @@ -1181,6 +1178,22 @@ TEST("switch()", wrap_root(function() CHECK(n0 == n1) end + + do CASE "strict" + vide.strict = true + + local input = source(0) + local output = switch(input) { + [0] = function() return 0 end, + [1] = function() return 1 end, + } + + CHECK(output() == 0) + input(1) + CHECK(output() == 1) + + vide.strict = false + end end)) TEST("indexes()", wrap_root(function() @@ -1364,6 +1377,21 @@ TEST("indexes()", wrap_root(function() CHECK(updated[3] == 2) CHECK(updated[4] == 2) end + + do CASE "strict" + vide.strict = true + + local input = source{1} + local output = indexes(input, function(v) + return { v } + end) + + CHECK(output()[1][1]() == 1) + input{2} + CHECK(output()[1][1]() == 2) + + vide.strict = false + end end)) TEST("values()", wrap_root(function() From 2ae65a0194fa8fea0a3eed6e3e6e57ed5d2e1943 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:24:14 +0100 Subject: [PATCH 35/56] Update tests Added a case to confirm the behavior of strict mode's double evaluation and node cached values. --- test/tests.luau | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/tests.luau b/test/tests.luau index eb5581c..7e95cca 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2019,6 +2019,22 @@ TEST("strict", wrap_root(function() CHECK(ok) end + + do CASE "effect counter" + local src = source(true) + + local count = 0 + + effect(function(x: number) + src() + count = x + 1 + return count + end, count) + + CHECK(count == 2) + src(not src()) + CHECK(count == 4) + end end)) local ok = FINISH() From bbda0426dacc253174b043e4db0afca974d73987 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:31:52 +0100 Subject: [PATCH 36/56] Update changelog --- CHANGELOG.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6423e..3900dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +-------------------------------------------------------------------------------- + ## Unreleased +### Added + +- `cleanup()` accepts objects with a `Destroy()` or `Disconnect()` interface. +- `read()` as a utility to read sources or passthrough a non-source value. + ### Changed - Reactive scopes created within reactive scopes are now destroyed on rerun. - `untrack()` can be called outside of reactive scopes. ---- +### Fixed + +- `show()` and `switch()` not updating when in strict mode. + +-------------------------------------------------------------------------------- ## [0.1.0] - 2023-09-20 From 3959f5119e406a77054bf91f7f20649f3bfc1145 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:33:38 +0100 Subject: [PATCH 37/56] Update `cleanup()` docs --- docs/api/reactivity-utility.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 9c7980a..a16e251 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -8,6 +8,11 @@ Runs a callback anytime a reactive scope is reran or destroyed. ```lua function cleanup(callback: () -> ()) + function cleanup(obj: Destroyable) + function cleanup(obj: Disconnectable) + + type Destroyable = { destroy: () -> () } + type Disconnectable = { disconnect: () -> () } ``` - **Example** From 9b6be14441c18fad6df2a2b9a9f94f571737fa2c Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 27 Sep 2023 16:44:39 +0100 Subject: [PATCH 38/56] Update docs --- docs/.vitepress/config.ts | 16 ++-- docs/tut/advanced/reactive-scoping.md | 7 +- docs/tut/crash-course/1-introduction.md | 11 ++- .../{8-cleanup.md => 10-cleanup.md} | 32 ++++++- .../{9-control-flow.md => 11-control-flow.md} | 94 +++++++++++++++++- ...erty-nesting.md => 12-property-nesting.md} | 0 .../{11-actions.md => 13-actions.md} | 0 .../{12-strict-mode.md => 14-strict-mode.md} | 0 docs/tut/crash-course/4-source.md | 12 ++- docs/tut/crash-course/5-effect.md | 41 ++------ docs/tut/crash-course/6-root.md | 49 ++++++++++ ...l-component.md => 7-stateful-component.md} | 3 + ...perty-binding.md => 8-property-binding.md} | 8 +- docs/tut/crash-course/9-derived-source.md | 95 +++++++++++++++++++ 14 files changed, 308 insertions(+), 60 deletions(-) rename docs/tut/crash-course/{8-cleanup.md => 10-cleanup.md} (64%) rename docs/tut/crash-course/{9-control-flow.md => 11-control-flow.md} (67%) rename docs/tut/crash-course/{10-property-nesting.md => 12-property-nesting.md} (100%) rename docs/tut/crash-course/{11-actions.md => 13-actions.md} (100%) rename docs/tut/crash-course/{12-strict-mode.md => 14-strict-mode.md} (100%) create mode 100644 docs/tut/crash-course/6-root.md rename docs/tut/crash-course/{6-stateful-component.md => 7-stateful-component.md} (95%) rename docs/tut/crash-course/{7-property-binding.md => 8-property-binding.md} (87%) create mode 100644 docs/tut/crash-course/9-derived-source.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index dec9146..7c820ff 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -43,13 +43,15 @@ export default withMermaid({ { text: "Components", link: "/tut/crash-course/3-components" }, { text: "Sources", link: "/tut/crash-course/4-source" }, { text: "Effects", link: "/tut/crash-course/5-effect" }, - { text: "Stateful Components", link: "/tut/crash-course/6-stateful-component" }, - { text: "Property Binding", link: "/tut/crash-course/7-property-binding" }, - { text: "Cleanup", link: "/tut/crash-course/8-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/9-control-flow" }, - { text: "Property Nesting", link: "/tut/crash-course/10-property-nesting" }, - { text: "Actions", link: "/tut/crash-course/11-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/12-strict-mode" }, + { text: "Root Scopes", link: "/tut/crash-course/6-root" }, + { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, + { text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, + { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, + { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, + { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, + { text: "Actions", link: "/tut/crash-course/13-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" } ] }, { diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md index f75525e..c9ed084 100644 --- a/docs/tut/advanced/reactive-scoping.md +++ b/docs/tut/advanced/reactive-scoping.md @@ -204,22 +204,23 @@ The above code produces a graph like so: "primaryBorderColor": "#1B1B1F", "lineColor": "#79B8FF", "tertiaryColor": "#161618", - "tertiaryBorderColor": "#fff" + "tertiaryBorderColor": "#1B1B1F" } }}%% flowchart LR subgraph root + direction LR show - subgraph subroot[show subroot] + subgraph subroot["show() subroot"] p1[prop binding] end end visible --> show count --> p1 - show .-> subroot + show -.- subroot ``` This can be recreated without the `show()` control-flow function, with the diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 371950a..ccf739a 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -15,11 +15,10 @@ worrying about manually updating UI instances. Some of the main focuses behind Vide's design choices: -- Concise syntax to reduce verbosity as much as possible. +- Concise syntax. - Being completely typecheckable. - 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. +- Real reactivity. ## Structure Of A Vide App @@ -33,8 +32,10 @@ called *components*. ```lua local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "hi" } + return { + PlayerStats(), + Inventory(), + Settings() } end diff --git a/docs/tut/crash-course/8-cleanup.md b/docs/tut/crash-course/10-cleanup.md similarity index 64% rename from docs/tut/crash-course/8-cleanup.md rename to docs/tut/crash-course/10-cleanup.md index 16d611d..df4614d 100644 --- a/docs/tut/crash-course/8-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -41,8 +41,30 @@ 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. -On a related note: the reason why `mount()` is used to create your app, is so -that any top-level components that need to be cleaned up, can be cleaned up -when the app is later unmounted, since `mount()` runs in a reactive scope to -track `cleanup()` calls. Vide's entire reactive system is independent from the -life-time of instances; instances are just a side-effect of the reactive system. +This is another reason why `mount()` is used at the top level of your app, so +that any registered cleanups created by your app components can be ran when +they are destroyed. + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +flowchart + +subgraph root + direction LR + cleanup([cleanup]) ~~~ count + count --> bind[text binding] +end +``` diff --git a/docs/tut/crash-course/9-control-flow.md b/docs/tut/crash-course/11-control-flow.md similarity index 67% rename from docs/tut/crash-course/9-control-flow.md rename to docs/tut/crash-course/11-control-flow.md index afeabfc..dcb0bbf 100644 --- a/docs/tut/crash-course/9-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -63,6 +63,37 @@ local function JoinMenu() end ``` +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +flowchart + +subgraph root ["mount() scope"] + direction LR + joined --> show -.- subroot + + subgraph subroot ["show() scope"] + direction LR + Button + end +end +``` + +The dotted line indicates that the new reactive scope isn't actually connected +to the `show` on the graph, it is only managed internally through code. + ## switch() Similar to `show()`, `switch()`, also condtionally displays one instance at a @@ -113,6 +144,34 @@ switch(menu) { } ``` +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +flowchart + +subgraph root ["mount() scope"] + direction LR + joined --> show -.- subroot + + subgraph subroot ["switch() scope"] + direction LR + Button + end +end +``` + ## indexes() Often, you will have a table of values that will be displayed in a similar @@ -121,7 +180,7 @@ UI element, `indexes()` allows you to create an instance for each table index, to display the value at that index. ```lua -local todoList = { +local todoList = source { "finish the crash course", "star vide's GitHub" } @@ -153,5 +212,38 @@ given source for that index is updated. An element is only destroyed if the value of an index is set to `nil`. +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +flowchart + +subgraph root ["mount() scope"] + direction LR + todoList --> indexes -.- subroot1 & subroot2 + + subgraph subroot1 ["indexes() scope 1"] + direction LR + value1[todo] --> prop1["prop binding"] + end + + subgraph subroot2 ["indexes() scope 2"] + direction LR + value2[todo] --> prop2[prop binding] + end +end +``` + Together, these control flow functions cover the majority of cases where you need to dynamically create and destroy parts of your UI. diff --git a/docs/tut/crash-course/10-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md similarity index 100% rename from docs/tut/crash-course/10-property-nesting.md rename to docs/tut/crash-course/12-property-nesting.md diff --git a/docs/tut/crash-course/11-actions.md b/docs/tut/crash-course/13-actions.md similarity index 100% rename from docs/tut/crash-course/11-actions.md rename to docs/tut/crash-course/13-actions.md diff --git a/docs/tut/crash-course/12-strict-mode.md b/docs/tut/crash-course/14-strict-mode.md similarity index 100% rename from docs/tut/crash-course/12-strict-mode.md rename to docs/tut/crash-course/14-strict-mode.md diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index 4419b72..f8f9e86 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -24,6 +24,9 @@ count(count() + 1) -- increment count by 1 Sources can be *derived* by wrapping them in functions. A wrapped source effectively becomes a new source. +Derived sources should be pure functions. This is where the same output is +always produced for the same input no matter how many times it is reran. + ```lua local count = source(0) @@ -36,7 +39,8 @@ count(1) print(text()) -- "count: 1" ``` -Derived sources should be pure functions. This is where the same output is -always produced for the same input. As well as making source updates more -predictable, knowing that updates are pure allows Vide to use optimizations such -as caching, to avoid updating derived sources if their inputs are the same. +Sources on their own aren't very special, the above can be achieved with plain +variables. The real use for sources become apparent when used in combination +with Vide's *reactive scopes*. When a source is read from within a reactive +scope, it can automatically rerun the scope that reads it when the source is +updated in the future. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 406ccd2..22cd199 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,8 +1,8 @@ # Effects Effects are functions that are ran in response to source updates. They are -called effects because they cause *side-effects* when reacting to source updates -which are pure. +called effects because they cause *side-effects* when reacting to source +updates. Effects are created using `effect()`. @@ -26,10 +26,10 @@ from inside a reactive scope will be tracked, so that if any of those sources update, the effect will be reran too. The callback is first ran immediately inside the `effect()` call to initially -figure out what sources are being used. +track sources used. -Effects also work with derived sources, it doesn't matter how deeply nested a -source is. +Effects also work with derived sources, it doesn't matter how deeply nested +inside a function a source is. ```lua local source = vide.source @@ -50,15 +50,7 @@ count(2) -- "doubled count: 4" printed ``` -Derived sources should be a *pure computation*. A pure computation is one where -the same input will always produce the same output. - -All observable changes to the user are considered to be side-effects of pure -computations. - -Sources, derived sources, and effects form what is called a *reactive graph*. -In the above example, the following graph is formed. Anywhere -an update occures, everything further down the graph is updated. +The reactive graph for the above example: ```mermaid %%{init: { @@ -69,25 +61,12 @@ an update occures, everything further down the graph is updated. "primaryBorderColor": "#1B1B1F", "lineColor": "#79B8FF", "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryBorderColor": "#fff" } }}%% flowchart LR - count --> doubled --> effect + +count --> effect + ``` - -You should not update other sources using an effect. Improper usage can lead to -a cyclic loop in the graph, causing an infinite loop when it tries to update. -Sources should be derived instead. - -## Root Reactive Scopes - -Effects must be created within another reactive scope. This is so that the -effect itself can be tracked and later freed when the parent reactive scope is -destroyed, such as from unmounting an app. The example code above will not -actually work unless it is ran inside a root reactive scope, such as one created -by `vide.mount(function)`. This generally isn't a concern since you can assume -that all your components will be created within a single `mount()` call, which -happens only once at the top level, where you put together your UI and parent it -to a ScreenGUI. diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md new file mode 100644 index 0000000..ff45184 --- /dev/null +++ b/docs/tut/crash-course/6-root.md @@ -0,0 +1,49 @@ +# Root Reactive Scopes + +Any reactive scopes created, such as one from `effect()`, must be done so within +a "root" reactive scope. This is the main purpose of `mount()`, which you use +once at the top level to create your app as shown in the first introduction. + +This is so that when the app is unmounted, it can clean up any reactive scopes +created within it, since reactive scopes track any reactive scopes created +within them. + +```lua +local source = vide.source +local effect = vide.effect + +local function App() + local count = source(0) + + effect(function() + print(count()) + end) +end + +vide.mount(App) -- works! + +App() -- will error since effect() was not called within a reactive scope +``` + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +flowchart + +subgraph root + direction LR + count --> effect +end +``` diff --git a/docs/tut/crash-course/6-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md similarity index 95% rename from docs/tut/crash-course/6-stateful-component.md rename to docs/tut/crash-course/7-stateful-component.md index 3d16279..246e050 100644 --- a/docs/tut/crash-course/6-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -30,6 +30,9 @@ end Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. +Making a property update based on a source is also called *property +binding*. + Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the scope of the component. diff --git a/docs/tut/crash-course/7-property-binding.md b/docs/tut/crash-course/8-property-binding.md similarity index 87% rename from docs/tut/crash-course/7-property-binding.md rename to docs/tut/crash-course/8-property-binding.md index 9488fba..c8985c9 100644 --- a/docs/tut/crash-course/7-property-binding.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -2,9 +2,7 @@ Explicitly creating effects to update properties can become verbose when there are a lot of properties to update. Vide provides a way to *implicitly* create -an effect to update properties on source update. This is also known as -*property binding*, since changes to a source will automatically update the -property. +an effect to update properties on source update. ```lua local create = vide.create @@ -40,7 +38,9 @@ that are updated. ## Children Binding -Children can also be set in a similar manner. +Children can also be set in a similar manner. Sources bound to properties can +return an instance or an array of instances. Vide will automatically unparent +removed instances and parent new instances. ```lua local items = source { diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md new file mode 100644 index 0000000..52574eb --- /dev/null +++ b/docs/tut/crash-course/9-derived-source.md @@ -0,0 +1,95 @@ +# Derived Sources + +We have seen the basic way to derive a source: + +```lua +local count = source(0) + +local text = function() + return "count: " .. tostring(count()) +end + +print(text()) -- "count: 0" +count(1) +print(text()) -- "count: 1" +``` + +However, in some cases where this source could be used by multiple effects at +the same time, the function wrapping the source will needlessly rerun to convert +the count into a string for each effect using it. + +```lua +local source = vide.source +local effect = vide.effect + +local count = source(0) + +local text = function() + print "ran" + return "count: " .. tostring(count()) +end + +effect(function() + text() -- prints "ran" +end) + +effect(function() + text() -- prints "ran" again +end) +``` + +To avoid this, you can use `derive()` to derive a new source instead. This will +run a callback in a new reactive scope only when a dependent source has updated. +Reading this derived source multiple times will just return a cached result from +when it last updated. + +```lua +local source = vide.source +local derive = vide.derive +local effect = vide.effect + +local count = source(0) + +local text = derive(function() + print "ran" + return "count: " .. tostring(count()) +end) + +effect(function() + text() -- prints "ran" +end) + +effect(function() + text() -- does not print +end) +``` + +Deriving a source in this manner is similar to creating an effect to update +another source. You should never manually do this using an effect however, +improper usage could accidently create infinite loops in the reactive graph. +Always favour deriving when you need one source to update based on another. + +`derive()` must also be used within a root reactive scope, just like `effect()`. + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +flowchart + +subgraph root + direction LR + count --> text --> effect1 & effect2 +end +``` From 46937c69790b6ffdcf008a34eea143400a590f0f Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 28 Sep 2023 16:39:25 +0100 Subject: [PATCH 39/56] Update docs --- docs/api/strict-mode.md | 5 +- docs/tut/advanced/reactive-scoping.md | 6 +- docs/tut/crash-course/1-introduction.md | 2 + docs/tut/crash-course/10-cleanup.md | 8 +-- docs/tut/crash-course/11-control-flow.md | 67 +++++++++++++------ docs/tut/crash-course/12-property-nesting.md | 2 +- docs/tut/crash-course/13-actions.md | 15 ++--- docs/tut/crash-course/14-strict-mode.md | 8 ++- docs/tut/crash-course/2-creation.md | 15 +---- docs/tut/crash-course/3-components.md | 37 ++++++---- docs/tut/crash-course/4-source.md | 7 +- docs/tut/crash-course/5-effect.md | 31 ++------- docs/tut/crash-course/6-root.md | 29 ++++++-- docs/tut/crash-course/7-stateful-component.md | 10 ++- docs/tut/crash-course/8-property-binding.md | 14 ++-- docs/tut/crash-course/9-derived-source.md | 17 +++-- 16 files changed, 157 insertions(+), 116 deletions(-) diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 35de4d8..958672e 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -22,8 +22,9 @@ Currently, strict mode will: 6. Checks for duplicate nested properties at same depth. 7. Better error reporting and stack traces + creation traces of property bindings. -By rerunning sources and effects, any side-effects are made more apparent. -This also helps ensure that cleanups are being handled correctly. +By rerunning derived sources and effects twice each time they update,it helps +ensure that derived source computations are pure, and that any +cleanups made in derived sources or effects are done correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, which strict mode can catch. diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md index c9ed084..20872cf 100644 --- a/docs/tut/advanced/reactive-scoping.md +++ b/docs/tut/advanced/reactive-scoping.md @@ -60,7 +60,7 @@ This code will produce a graph that looks like so: } }}%% -flowchart +graph subgraph root forename & surname --> name name --> effect @@ -145,7 +145,7 @@ This code produces a graph like so: } }}%% -flowchart LR +graph LR subgraph root counters --> indexes @@ -208,7 +208,7 @@ The above code produces a graph like so: } }}%% -flowchart LR +graph LR subgraph root direction LR show diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index ccf739a..604f7ae 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -5,6 +5,8 @@ Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). +This tutorial assumes familiarity with Luau and Roblox GUI. + ## Why Vide? Creating UI is a slow and tedious process. The purpose of Vide is to make UI diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index df4614d..4f62233 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -6,7 +6,7 @@ is used to register a cleanup callback for the next time the reactive scope it is called in re-runs. ```lua -locla mount = vide.mount +local mount = vide.mount local source = vide.source local cleanup = vide.cleanup @@ -60,11 +60,11 @@ The reactive graph for the above example: } }}%% -flowchart +graph -subgraph root +subgraph mount direction LR cleanup([cleanup]) ~~~ count - count --> bind[text binding] + count --> bind["effect (text binding)"] end ``` diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index dcb0bbf..e5430b8 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -11,7 +11,7 @@ will update when the input source updates. Control flow functions are special, because they run their components in a new reactive scope, which can be destroyed independently of the reactive scope that called the control flow function itself. This means that parts of your app can -be independently created then destroyed and cleaned. +be independently created then destroyed. ## show() @@ -78,21 +78,24 @@ The reactive graph for the above example: } }}%% -flowchart +graph -subgraph root ["mount() scope"] +subgraph root["mount() scope"] direction LR joined --> show -.- subroot - subgraph subroot ["show() scope"] + subgraph subroot["show() scope"] direction LR Button end end ``` -The dotted line indicates that the new reactive scope isn't actually connected -to the `show` on the graph, it is only managed internally through code. +`show()` will implicitly create an effect depending on `joined`, which can be +seen as `show` on the graph. This effect manages, and can create or destroy +a separate reactive scope seen as `show() scope` on the graph. The dotted line +indicates that it isn't actually connected, only indirectly managed through +code. ## switch() @@ -135,11 +138,11 @@ The switch can map any value to any component. ```lua type ActiveMenu = "none" | "inventory" | "shop" | "settings" -local menu = source "none" +local menu = source "inventory" switch(menu) { inventory = InventoryMenu, - shop = ShopMenu. + shop = ShopMenu, settings = SettingsMenu } ``` @@ -159,25 +162,25 @@ The reactive graph for the above example: } }}%% -flowchart +graph -subgraph root ["mount() scope"] +subgraph root["mount() scope"] direction LR - joined --> show -.- subroot + menu --> switch -.- subroot - subgraph subroot ["switch() scope"] + subgraph subroot["switch() scope"] direction LR - Button + Menu end end ``` ## indexes() -Often, you will have a table of values that will be displayed in a similar +Often, you will have a table of values with each value displayed in a similar manner. Rather than manually looping over each value to generate a corresponding -UI element, `indexes()` allows you to create an instance for each table index, -to display the value at that index. +UI element, `indexes()` allows you to create elements for each table index, to +display the value at that index. ```lua local todoList = source { @@ -204,13 +207,22 @@ end TodoList { list = todoList } ``` -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. +For each index in the given source table, the given function will be called +with: + +1. a source containing the value of the index +2. the index itself When the value at an index is changed, the function is not reran. Instead, the given source for that index is updated. -An element is only destroyed if the value of an index is set to `nil`. +Any time the input source table is updated, the given function will be ran for +any newly added indexes, while any removed indexes (indexes now with a `nil` +value), will have its corresponding reactive scope destroyed to clean up that +element. + +`indexes()` is said to *map* each table index to a new UI element that can +update to display the current value at that index. The reactive graph for the above example: @@ -227,7 +239,7 @@ The reactive graph for the above example: } }}%% -flowchart +graph subgraph root ["mount() scope"] direction LR @@ -245,5 +257,20 @@ subgraph root ["mount() scope"] end ``` +One thing to note regarding table sources, is that when you edit a table in a +source, you must set that table again to actually update the source. + +```lua +local src = source { 1, 2 } +local data = src() +table.insert(data, 3) -- no effects will run +src(data) -- effects will run +``` + Together, these control flow functions cover the majority of cases where you need to dynamically create and destroy parts of your UI. + +If you need to do something that these control flow functions cannot, you can +always use `mount()` within an effect to dynamically create and destroy +components on your own terms. Just remember to use `cleanup()` to unmount when +the effect reruns. diff --git a/docs/tut/crash-course/12-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md index ea2ae43..d8c44ce 100644 --- a/docs/tut/crash-course/12-property-nesting.md +++ b/docs/tut/crash-course/12-property-nesting.md @@ -77,7 +77,7 @@ be parented. ```lua type Children = { - -- allows us to also optionally pass a source that returns an array of children instead + -- also can optionally pass a source that returns an array of children too Children = Array | () -> Array } diff --git a/docs/tut/crash-course/13-actions.md b/docs/tut/crash-course/13-actions.md index 79ca7c7..06b431d 100644 --- a/docs/tut/crash-course/13-actions.md +++ b/docs/tut/crash-course/13-actions.md @@ -1,8 +1,7 @@ # Actions Actions in Vide are special callbacks that you can pass along with properties, -which will be called when those properties are being processed with the instance -being assigned to, allowing you to run custom code. +to run some code on an instance receiving them. ```lua local action = vide.action @@ -20,24 +19,22 @@ create "TextLabel" { -- will print "test" ``` -Actions can be wrapped with functions to re-use specific behaviors. Below is -an example of an action used to listen for property changes: +Actions can be wrapped with functions for reuse. Below is an example of an +action used to listen for property changes: ```lua local action = vide.action local cleanup = vide.cleanup -local function changed(property: string, callback: (new) -> ()) +local function changed(prop: string, callback: (new) -> ()) return action(function(instance) - local con = instance:GetPropertyChangedSignal(property):Connect(function() + local connection = instance:GetPropertyChangedSignal(prop):Connect(function() callback(instance[property]) end) -- remember to clean up the connection when the reactive scope the action -- is ran in is destroyed, so the instance can be garbage collected - cleanup(function() - con:Disconnect() - end) + cleanup(connection) end) end diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/14-strict-mode.md index 3cb09e6..ca8465a 100644 --- a/docs/tut/crash-course/14-strict-mode.md +++ b/docs/tut/crash-course/14-strict-mode.md @@ -5,9 +5,13 @@ be set with `vide.strict = true` once when you first require Vide. Strict mode will add extra safety checks and emit better error traces, particularly when errors occur in property bindings. +Strict mode is automatically enabled when Vide is required in O0 or O1 +optimization (default studio level). You can `vide.strict = false` if you do not +want this. + Strict mode will run derived sources and effects twice each time they update. -This is to help identify improper cleanup of side-effects and ensure that pure -computations are actually pure. +This is to help ensure that derived source computations are pure, and that any +cleanups made in derived sources or effects are done correctly. ```lua local source = vide.source diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 68e3007..e66f987 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -45,19 +45,10 @@ Assign a value to a string key to set a property, and assign a value to a number key to set a child. Events can be connected to by assigning a function to a string key. -You can also use a shorthand to create datatypes instead of explicitly typing -out the class name and constructor. The table will be unpacked into the `.new()` -constructor of the property's type. - -```lua -create "Frame" { - AnchorPoint = { 0.5, 1 }, - UDim2 = { 0.5, 0, 0.5, 0 } -} -``` - +::: warning When creating an instance with no properties, it is important to not forget to actually call the constructor: `create "Frame" {}` and not `create "Frame"`. To be clear, `create "Frame"` returns a *function* which is a constructor for that class, not an instance of that class. This would result in you attempting -to parent a function instead of an instance which is not the correct behavior. +to parent a function instead of an instance which is not correct. +::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 3050cf8..a9f38d9 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,9 +1,11 @@ # Components -Components are custom-made reusable pieces of UI made from other pieces of UI. +A component is a function that creates and returns a piece of UI. -By using components you can make your application more modular and better -organized. +This is a way to separate your app into small chunks that you can reuse and put +together. + +::: code-group ```lua [Button.luau] local create = vide.create @@ -15,11 +17,14 @@ local function Button(props: { }) return create "TextButton" { BackgroundColor3 = Color3.fromRGB(50, 50, 50), + TextColor3 = Color3.fromRGB(255, 255, 255), Size = UDim2.fromOffset(200, 150), Position = props.Position, Text = props.Text, - Activated = props.Activated + Activated = props.Activated, + + create "UICorner" {} } end @@ -36,10 +41,17 @@ local function App() return create "ScreenGui" { Button { Position = UDim2.fromOffset(200, 200), - Text = "click me!", - + Text = "back", Activated = function() - print "clicked" + print "go to previous page" + end + }, + + Button { + Position = UDim2.fromOffset(400, 200), + Text = "next", + Activated = function() + print "go to next page" end } } @@ -48,8 +60,9 @@ end mount(App, game.StarterGui) ``` -Above is a simple example of a button component with a set color and size, -being reused across files. +::: + +Above is a simple example of a button component being used across files. A single parameter `props` is used to pass properties to the component. @@ -57,8 +70,8 @@ Components allow you to *encapsulate* behavior. You can only modify the component in ways that you allow in the component, through the `props` parameter. To create a new button all you must do is call the `Button` function, passing in -values through props. This saves having to create and set every property each -time. Also, when updating the button component in future, any changes to the -button file will be seen anywhere the button is used in your app. +values. This saves having to create and set every property each time. Also, when +updating the button component in future, any changes to the button file will be +seen anywhere the button is used in your app. 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 f8f9e86..f5a848a 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,10 +1,9 @@ # Sources -*Sources* in Vide are special objects that store a single value. They are the -core of reactivity in Vide. Each source represents a source of data, and they -can be composed and derived to create new sources of data. +Sources are special objects that store a single value. They are the core of +Vide's reactivity. They are called sources because they act as sources of data. -A source in Vide can be created using `source()`. +A source can be created using `source()`. ```lua local source = vide.source diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 22cd199..9f0dd6b 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -21,12 +21,9 @@ count(1) -- "count: 1" printed ``` -The callback given to `effect()` is ran in a *reactive scope*. Any source read -from inside a reactive scope will be tracked, so that if any of those sources -update, the effect will be reran too. - -The callback is first ran immediately inside the `effect()` call to initially -track sources used. +The callback given to `effect()` is initially ran immediately in a +*reactive scope*. Any source read from inside a reactive scope will be tracked, +so that if any of those sources update, the effect will be reran too. Effects also work with derived sources, it doesn't matter how deeply nested inside a function a source is. @@ -50,23 +47,5 @@ count(2) -- "doubled count: 4" printed ``` -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#fff" - } -}}%% - -flowchart LR - -count --> effect - -``` +If a source is updated with the same value it already had, it will not rerun +effects depending on it. diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md index ff45184..2b2d7ad 100644 --- a/docs/tut/crash-course/6-root.md +++ b/docs/tut/crash-course/6-root.md @@ -1,7 +1,7 @@ # Root Reactive Scopes -Any reactive scopes created, such as one from `effect()`, must be done so within -a "root" reactive scope. This is the main purpose of `mount()`, which you use +Any reactive scopes created, such as by `effect()`, must be done so within a +"root" reactive scope. This is the main purpose of `mount()`, which you use once at the top level to create your app as shown in the first introduction. This is so that when the app is unmounted, it can clean up any reactive scopes @@ -25,7 +25,18 @@ vide.mount(App) -- works! App() -- will error since effect() was not called within a reactive scope ``` -The reactive graph for the above example: +Mounting returns a function that when called will destroy any reactive scopes +created during the `mount()` call. + +```lua +local unmount = mount(App) + +unmount() +``` + +Vide's reactivity can be represented graphically, as a *reactive graph*. + +The reactive graph for the above example looks like so: ```mermaid %%{init: { @@ -40,10 +51,18 @@ The reactive graph for the above example: } }}%% -flowchart +graph -subgraph root +subgraph root["mount"] direction LR count --> effect end ``` + +When the `mount` scope is destroyed, the `effect` scope will also be destroyed +since it was created within it. + +You don't need to worry about ensuring all your effects are created within a +root scope, since you should be creating all your UI and corresponding effects +within a top-level `mount()` call that puts all your UI together. So it is safe +to assume that any effect you create will be created under this top level scope. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 246e050..8de8209 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -5,6 +5,8 @@ A stateful component is a component that stores and displays some data. Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. +## Internal State + ```lua local create = vide.create local source = vide.source @@ -30,12 +32,14 @@ end Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. -Making a property update based on a source is also called *property +Making a property update based on a source is also referred to as *property binding*. Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the scope of the component. +## External State + External sources can also be passed into components for them to use. ```lua @@ -65,5 +69,5 @@ count(1) -- the Counter component will update to display this count ``` Sources can be created internally or passed in from externally, there are no -restrictions on how they are used as long as the effect is created within a -reactive scope so that it can be tracked. +restrictions on how they are used as long as the effect using it is created +within a reactive scope so that it can be cleaned up later. diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md index c8985c9..8304565 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -20,6 +20,7 @@ local function Counter() count(count() + 1) end } +end ``` This example is equivalent to the example seen on the previous page. @@ -32,15 +33,16 @@ Just like effects, the function is ran immediately in a reactive scope to set the property initially and determine what sources are being depended on. 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. +of your program changes. You just define how the data sources map to UI, and +Vide's reactive system will automatically update any properties depending on +those sources that were updated. ## Children Binding -Children can also be set in a similar manner. Sources bound to properties can -return an instance or an array of instances. Vide will automatically unparent -removed instances and parent new instances. +Children can also be set in a similar manner. A source passed as a child (passed +with a number key instead of string key) can return an instance or an array of +instances. Vide will automatically unparent removed instances and parent new +instances when that source's stored instances change. ```lua local items = source { diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 52574eb..17e3b77 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -60,17 +60,15 @@ effect(function() end) effect(function() - text() -- does not print + text() -- does not print, returns cached value end) ``` -Deriving a source in this manner is similar to creating an effect to update -another source. You should never manually do this using an effect however, -improper usage could accidently create infinite loops in the reactive graph. -Always favour deriving when you need one source to update based on another. - `derive()` must also be used within a root reactive scope, just like `effect()`. +If the recalculated value is the same as the old value, the derived source will +not rerun the effects using it. + The reactive graph for the above example: ```mermaid @@ -86,10 +84,15 @@ The reactive graph for the above example: } }}%% -flowchart +graph subgraph root direction LR count --> text --> effect1 & effect2 end ``` + +Deriving a source in this manner is similar to creating an effect to update +another source. You should never manually do this using an effect however, +improper usage could accidently create infinite loops in the reactive graph. +Always favour deriving when you need one source to update based on another. From c03fb0577933a3eec6e579b7ff7ae54d49838d3d Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 28 Sep 2023 16:46:32 +0100 Subject: [PATCH 40/56] Make `changed()` run initially --- CHANGELOG.md | 1 + docs/api/creation.md | 20 ++++++++++++++++++++ src/changed.luau | 2 ++ test/tests.luau | 2 +- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3900dad..2a576af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Reactive scopes created within reactive scopes are now destroyed on rerun. - `untrack()` can be called outside of reactive scopes. +- `changed()` will also run its callback with the initial property value. ### Fixed diff --git a/docs/api/creation.md b/docs/api/creation.md index f3f51c8..b4576b8 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -154,3 +154,23 @@ instances. changed("Text", output) } ``` + +## changed() + +A wrapper for `action()` to listen for property changes. + +- **Type** + + ```lua + function changed(property: string, callback: (...unknown) -> ()): Action + ``` + +- **Details** + + Will run the given callback any time the property is changed, as well as + when the action is initially run. + + The changed connection is disconnected when the reactive scope the action is + ran in is destroyed. + + Runs with an action priority of 1. diff --git a/src/changed.luau b/src/changed.luau index 5d439c6..519a554 100644 --- a/src/changed.luau +++ b/src/changed.luau @@ -12,6 +12,8 @@ local function changed(property: string, callback: (T) -> ()) cleanup(function() con:Disconnect() end) + + callback((instance :: any)[property]) end) end diff --git a/test/tests.luau b/test/tests.luau index 7e95cca..a82712b 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1806,7 +1806,7 @@ TEST("changed()", wrap_root(function() changed("Text", output) } - --CHECK(output() == "a") + CHECK(output() == "a") text.Text = "b" CHECK(output() == "b") end From 7128b9a58b7b1e162fa74b6445720c221acf51eb Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 30 Sep 2023 01:32:14 +0100 Subject: [PATCH 41/56] Bump version to `v0.1.1` --- CHANGELOG.md | 6 ++++++ src/init.luau | 2 +- wally.toml | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a576af..558b398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +- + +-------------------------------------------------------------------------------- + +## [0.1.1] - 2023-09-30 + ### Added - `cleanup()` accepts objects with a `Destroy()` or `Disconnect()` interface. diff --git a/src/init.luau b/src/init.luau index 62b6281..687f417 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- vide.luau --- v0.1.0 +-- v0.1.1 -------------------------------------------------------------------------------- if not game then script = require "test/relative-string" end diff --git a/wally.toml b/wally.toml index f118dc8..d4d48ed 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.1.0" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] @@ -16,4 +16,4 @@ exclude = [ "CHANGELOG.md", "README.md", "todo.md" -] \ No newline at end of file +] From da85cbbac85f7b0040812ab3ab4c62ee1eae467d Mon Sep 17 00:00:00 2001 From: Alo <70312917+Aloroid@users.noreply.github.com> Date: Sat, 7 Oct 2023 00:16:01 +0200 Subject: [PATCH 42/56] Fix docs typo minor spelling mistake --- docs/api/creation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/creation.md b/docs/api/creation.md index b4576b8..8e67478 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -69,7 +69,7 @@ Creates a new UI element, applying any given properties. - **index is number:** - **value is action:** run action - **value is table:** recurse table - - **value is functon:** create effect to update children + - **value is function:** create effect to update children - **value is instance:** set instance as child - **Example** From 8218e4702fc525e5491ea68d0479458cd867cd17 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 25 Oct 2023 15:49:35 +0100 Subject: [PATCH 43/56] Fix diamond graphs --- src/graph.luau | 83 +++++++++++++++++++++++++++++++++--------------- test/tests.luau | 84 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index bb42fbb..0c0bf21 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -181,47 +181,80 @@ local function evaluate_node(node: Node) 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 function update_from(node: StartNode, n0: number) +-- if not node[1] then return end - local n = n0 +-- local n = n0 - -- unparent all children and queue for eval - do - local child = node[1] - while child do - --assert(child.parents.owner) - unparent(child) - n += 1 - update_queue[n] = child - child = node[1] - end +-- -- unparent all children and queue for eval +-- do +-- local child = node[1] +-- while child do +-- --assert(child.parents.owner) +-- unparent(child) +-- n += 1 +-- update_queue[n] = child +-- child = node[1] +-- end +-- end + +-- update_queue.n = n + +-- -- evaluate all queued children +-- for i = n0 + 1, n do +-- local child = update_queue[i] +-- assert(type(child.effect) == "function") + +-- if evaluate_node(child) then +-- update_from(child, n) +-- end + +-- update_queue[i] = false :: any -- false instead of nil to avoid sparse +-- end + +-- update_queue.n = n0 +-- end + +-- local function update(node: StartNode) +-- update_from(node, update_queue.n) +-- end + +local function queue_children(node: StartNode) + local i = update_queue.n + local child = node[1] + while child do + --assert(child.parents.owner) + unparent(child) + i += 1 + update_queue[i] = child + child = node[1] end + update_queue.n = i +end - update_queue.n = n +local function update(root: StartNode) + local n0 = update_queue.n + queue_children(root) - -- evaluate all queued children - for i = n0 + 1, n do - local child = update_queue[i] - assert(type(child.effect) == "function") + local i = n0 + 1 + while i <= update_queue.n do + local node = update_queue[i] + assert(node.effect) - if evaluate_node(child) then - update_from(child, n) + if evaluate_node(node) then + queue_children(node) end update_queue[i] = false :: any -- false instead of nil to avoid sparse + i += 1 end update_queue.n = n0 end -local function update(node: StartNode) - update_from(node, update_queue.n) -end - local function track(node: StartNode) local scope = get_scope() - if scope and type(scope.effect) == "function" then -- do not track nodes with no effect + if scope and scope.effect then -- do not track nodes with no effect add_child(node, scope) end end diff --git a/test/tests.luau b/test/tests.luau index a82712b..0c72f90 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,5 +1,5 @@ local testkit = require("test/testkit") -local TEST, CASE, CHECK, FINISH = testkit.test() +local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() local mock = require "test/mock" local Instance, Signal = mock.Instance, mock.Signal @@ -33,6 +33,8 @@ local NIL = nil :: any vide.strict = false +--SKIP "graph edge cases" + TEST("graph", function() local create_node = graph.create_node local track = graph.track @@ -1907,7 +1909,85 @@ TEST("nested effects cases", function() root(App) end) -vide.strict = true +TEST("graph edge cases", wrap_root(function() + local source = vide.source + local derive = vide.derive + local effect = vide.effect + + do CASE "diamond A,B,C,D" + --[[ + + a > b > d + > c > + + ]] + + local a = source(0) + + local b = derive(function() return (a() % 2 == 0) and 1 or 0 end) + local c = derive(function() return a() * 2 end) + local d = derive(function() return b() + c() end) + + local count = { b = 0, c = 0, d = 0 } + effect(function() b(); count.b += 1 end) + effect(function() c(); count.c += 1 end) + effect(function() d(); count.d += 1 end) + + a(1) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(d() == 2) + + a(3) + CHECK(count.b == 2) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(d() == 6) + end + + do CASE "diamond A,B,C,D,E" + --[[ + + a > b > > e + > c > d > + + ]] + + local a = source(0) + + local b = derive(function() print "ran b"; return (a() % 2 == 0) and 1 or 0 end) + local c = derive(function() print "ran c"; return a() * 2 end) + local d = derive(function() print "ran d"; return c() * 2 end) + local e = derive(function() print "ran e"; return b() + c() end) + + local count = { b = 0, c = 0, d = 0, e = 0 } + effect(function() b(); count.b += 1 end) + effect(function() c(); count.c += 1 end) + effect(function() d(); count.d += 1 end) + effect(function() d(); count.e += 1 end) + + a(1) + + print(e()) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(count.e == 2) + CHECK(e() == 4) -- todo: solve e evaluating before d + + a(3) + CHECK(count.b == 2) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(count.e == 3) + CHECK(e() == 12) + end + + do CASE "repeated read" + + end +end)) TEST("strict", wrap_root(function() vide.strict = true From 15255855c3bad0f0af75fdf84297cdbc005a8ead Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:58:29 +0100 Subject: [PATCH 44/56] Add repeated read test --- src/graph.luau | 38 -------------------------------------- test/tests.luau | 9 ++++++++- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index 0c0bf21..ab0be0b 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -181,44 +181,6 @@ local function evaluate_node(node: Node) 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 --- --assert(child.parents.owner) --- unparent(child) --- n += 1 --- update_queue[n] = child --- child = node[1] --- end --- end - --- update_queue.n = n - --- -- evaluate all queued children --- for i = n0 + 1, n do --- local child = update_queue[i] --- assert(type(child.effect) == "function") - --- if evaluate_node(child) then --- update_from(child, n) --- end - --- update_queue[i] = false :: any -- false instead of nil to avoid sparse --- end - --- update_queue.n = n0 --- end - --- local function update(node: StartNode) --- update_from(node, update_queue.n) --- end - local function queue_children(node: StartNode) local i = update_queue.n local child = node[1] diff --git a/test/tests.luau b/test/tests.luau index 0c72f90..b431a90 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1969,7 +1969,6 @@ TEST("graph edge cases", wrap_root(function() a(1) - print(e()) CHECK(count.b == 2) CHECK(count.c == 2) CHECK(count.d == 2) @@ -1985,7 +1984,15 @@ TEST("graph edge cases", wrap_root(function() end do CASE "repeated read" + local a = source(0) + local b = derive(function() return a() + a() end) + local count = 0 + effect(function() b(); count += 1 end) + + a(1) + CHECK(b() == 2) + CHECK(count == 2) end end)) From aca08709b6ecaee21dbb0586d659e95eb20fd800 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 25 Oct 2023 20:02:40 +0100 Subject: [PATCH 45/56] Update tests --- test/benchmark.luau | 7 ++++--- test/tests.luau | 31 ------------------------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/test/benchmark.luau b/test/benchmark.luau index f3c39b6..aac9071 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -132,22 +132,23 @@ ROOT_BENCH("update 1->1->1->1...1000 graph", function() end end) +-- todo: crashes at 1k -- todo: repeat with batching ROOT_BENCH("update 1000->1 graph", function() local srcs = {} - for i = 1, 1000 do + for i = 1, 800 do srcs[i] = source(0) end derive(function() - for i = 1, 1000 do + for i = 1, 800 do srcs[i]() end return false end) for i = 1, START(1) do - for idx = 1, 1000 do + for idx = 1, 800 do srcs[idx](i) end end diff --git a/test/tests.luau b/test/tests.luau index b431a90..0f22371 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -136,37 +136,6 @@ TEST("graph", function() CHECK(d_cnt == 1) end - do CASE "diamond graph 2" - -- todo: include cached value from parent nodes to confirm update order - -- a -> b -> c -> e - -- -> d - local root = node() - local a, b, c, d, e = node(), node(), node(), node(), node() - - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) - set_owner(e, root) - - local b_cnt, c_cnt, d_cnt, e_cnt = 0, 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 - function e.effect(x) e_cnt += 1; return not x end - - open_scope(b); track(a); close_scope() - open_scope(c); track(b); close_scope() - open_scope(d); track(a); close_scope() - open_scope(e); track(c); track(d); close_scope() - - update(a) - - CHECK(b_cnt == 1) - CHECK(c_cnt == 1) - CHECK(d_cnt == 1) - CHECK(e_cnt == 1) - end - do CASE "duplicate child on rerun" local root = node() local a, b, c = node(), node(), node() From 7d82fe353e1d0865f5ef267e02b5b2056e3e61e3 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Fri, 27 Oct 2023 15:47:43 +0100 Subject: [PATCH 46/56] Fix error in test Also misc changes --- src/graph.luau | 33 ++++++++++++++++----------------- src/spring.luau | 3 ++- test/tests.luau | 24 ++++++++++++------------ todo.md | 10 ++++------ 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index ab0be0b..d316e78 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -42,12 +42,14 @@ end local function get_owning_scope(): Node local scope = get_scope() + if not scope then local caller_name = debug.info(2, "n") return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) elseif scope.effect then - throw("reactive scope is not an owning scope; new effects cannot be created in side-effects") + throw("cannot create new reactive scope inside of a tracking scope") -- todo: allow this? end + return scope end @@ -96,8 +98,8 @@ local function run_cleanups(node: Node) end local function find_and_swap_pop(t: { T }, v: T) - local idx = table.find(t, v) - assert(idx, "value not found") + local idx = table.find(t, v) :: number + --assert(idx, "value not found") local n = #t t[idx] = t[n] t[n] = nil @@ -107,10 +109,9 @@ local function remove_child(parent: StartNode, child: Node) find_and_swap_pop(parent, child) end -local function remove_owner(node: Node) - local owner = node.owner :: Node - if node.owner and owner.owned then - find_and_swap_pop(owner.owned, node) +local function disown(node: Node) + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) end end @@ -126,7 +127,7 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) - remove_owner(node) + disown(node) if node.owned then local owned = node.owned @@ -137,7 +138,8 @@ end local function destroy_owned(node: Node) if node.owned then - while node.owned[1] do destroy(node.owned[1]) end + local owned = node.owned + while owned[1] do destroy(owned[1]) end end end @@ -178,18 +180,15 @@ local function evaluate_node(node: Node) node.cache = new_value - return cur_value ~= new_value -- node has changed value + return cur_value ~= new_value end local function queue_children(node: StartNode) local i = update_queue.n - local child = node[1] - while child do - --assert(child.parents.owner) - unparent(child) + while node[1] do i += 1 - update_queue[i] = child - child = node[1] + update_queue[i] = node[1] + unparent(node[1]) end update_queue.n = i end @@ -201,7 +200,7 @@ local function update(root: StartNode) local i = n0 + 1 while i <= update_queue.n do local node = update_queue[i] - assert(node.effect) + --assert(node.effect) if evaluate_node(node) then queue_children(node) diff --git a/src/spring.luau b/src/spring.luau index 66dc896..b627a74 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -161,7 +161,8 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local c_c = 2*w_n local c = z * c_c - -- todo: is there a solution to this other than upping step frequency? + -- todo: is there a solution other than reducing step size? + -- todo: this does not catch all solver exploding cases if c > UPDATE_RATE*2 then -- solver will explode if this is true throw("spring damping too high, consider reducing damping or increasing period") end diff --git a/test/tests.luau b/test/tests.luau index 0f22371..5c51e5f 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,5 +1,5 @@ local testkit = require("test/testkit") -local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() +local TEST, CASE, CHECK, FINISH = testkit.test() local mock = require "test/mock" local Instance, Signal = mock.Instance, mock.Signal @@ -33,8 +33,6 @@ local NIL = nil :: any vide.strict = false ---SKIP "graph edge cases" - TEST("graph", function() local create_node = graph.create_node local track = graph.track @@ -1918,37 +1916,39 @@ TEST("graph edge cases", wrap_root(function() do CASE "diamond A,B,C,D,E" --[[ - a > b > > e + a > b > e > c > d > ]] local a = source(0) - local b = derive(function() print "ran b"; return (a() % 2 == 0) and 1 or 0 end) - local c = derive(function() print "ran c"; return a() * 2 end) - local d = derive(function() print "ran d"; return c() * 2 end) - local e = derive(function() print "ran e"; return b() + c() end) + local b = derive(function() return (a() % 2 == 0) and 1 or 0 end) + local c = derive(function() return a() * 2 end) + local d = derive(function() return c() * 2 end) + local e = derive(function() return b() + d() end) local count = { b = 0, c = 0, d = 0, e = 0 } effect(function() b(); count.b += 1 end) effect(function() c(); count.c += 1 end) effect(function() d(); count.d += 1 end) - effect(function() d(); count.e += 1 end) + effect(function() e(); count.e += 1 end) + + CHECK(e() == 1) a(1) CHECK(count.b == 2) CHECK(count.c == 2) CHECK(count.d == 2) - CHECK(count.e == 2) - CHECK(e() == 4) -- todo: solve e evaluating before d + CHECK(count.e == 3) -- todo: redundant re-eval + CHECK(e() == 4) a(3) CHECK(count.b == 2) CHECK(count.c == 3) CHECK(count.d == 3) - CHECK(count.e == 3) + CHECK(count.e == 4) CHECK(e() == 12) end diff --git a/todo.md b/todo.md index 8daf7a7..1256cdd 100644 --- a/todo.md +++ b/todo.md @@ -1,12 +1,10 @@ # todo -- property binding optimization - - would no longer allow `cleanup()` usage in binding scopes -- solution to nested reactivity, see: SolidJS stores - optimize wide graph updating - implement from solid: - - Portal - - batch + - stores + - portals + - batch() - optimize `indexes()` double-diffing -- improve crash course, some sections feel like information dumps - cleanup source and tests +- prevent redundant re-eval of nodes in a complex diamond graph From ec998ccbc8ddd229a94039c76268de7286843580 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Fri, 27 Oct 2023 16:47:49 +0100 Subject: [PATCH 47/56] Implement batched updates No docs yet, more testing needed. --- src/batch.luau | 23 +++++++++++++++++++++++ src/flags.luau | 2 +- src/graph.luau | 23 +++++++++++++++++++++++ src/init.luau | 4 +++- test/benchmark.luau | 38 ++++++++++++++++++++++++++++++-------- test/tests.luau | 43 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 src/batch.luau diff --git a/src/batch.luau b/src/batch.luau new file mode 100644 index 0000000..3c08127 --- /dev/null +++ b/src/batch.luau @@ -0,0 +1,23 @@ +if not game then script = require "test/relative-string" end + +local flags = require(script.Parent.flags) +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) + +local function batch(setter: () -> ()) + local already_batching = flags.batch + + flags.batch = true + + local ok, err: string? = pcall(setter) + + flags.batch = false + + if not ok then throw(`error occured while batching updates: {err}`) end + + if not already_batching then -- todo: flush anyways? + graph.flush_update_queue() + end +end + +return batch diff --git a/src/flags.luau b/src/flags.luau index 1b9f80e..cc2d2f8 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -4,4 +4,4 @@ end local is_O2 = inline_test() ~= "inline_test" -return { strict = not is_O2 } +return { strict = not is_O2, batch = false } diff --git a/src/graph.luau b/src/graph.luau index d316e78..e32f3d0 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -193,10 +193,32 @@ local function queue_children(node: StartNode) update_queue.n = i end +local function flush_update_queue() + -- todo: test with recursive batch sets + local n0 = 0 + + local i = n0 + 1 + while i <= update_queue.n do + local node = update_queue[i] + --assert(node.effect) + + if evaluate_node(node) then + queue_children(node) + end + + update_queue[i] = false :: any + i += 1 + end + + update_queue.n = n0 +end + local function update(root: StartNode) local n0 = update_queue.n queue_children(root) + if flags.batch then return end + local i = n0 + 1 while i <= update_queue.n do local node = update_queue[i] @@ -257,5 +279,6 @@ return table.freeze { create_node = create_node, create_start_node = create_start_node, get_children = get_children, + flush_update_queue = flush_update_queue, scopes = scopes } diff --git a/src/init.luau b/src/init.luau index 687f417..cfc49d0 100644 --- a/src/init.luau +++ b/src/init.luau @@ -11,9 +11,10 @@ local create = require(script.create) local apply = require(script.apply) local source = require(script.source) local effect = require(script.effect) +local derive = require(script.derive) local cleanup = require(script.cleanup) local untrack = require(script.untrack) -local derive = require(script.derive) +local batch = require(script.batch) local switch = require(script.switch) local show = require(script.show) local indexes, values = require(script.maps)() @@ -59,6 +60,7 @@ local vide = { -- util cleanup = cleanup, untrack = untrack, + batch = batch, read = function(value: T | () -> T): T return if type(value) == "function" then value() else value end, diff --git a/test/benchmark.luau b/test/benchmark.luau index aac9071..bee8c68 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -6,6 +6,7 @@ local source = vide.source local derive = vide.derive local indexes = vide.indexes local values = vide.values +local batch = vide.batch local cleanup = vide.cleanup local create = vide.create @@ -132,30 +133,51 @@ ROOT_BENCH("update 1->1->1->1...1000 graph", function() end end) --- todo: crashes at 1k --- todo: repeat with batching -ROOT_BENCH("update 1000->1 graph", function() +-- todo: why does it hang at 1k? it didn't before +ROOT_BENCH("update 500->1 graph", function() local srcs = {} - for i = 1, 800 do + for i = 1, 500 do srcs[i] = source(0) end derive(function() - for i = 1, 800 do + for i = 1, 500 do srcs[i]() end return false end) for i = 1, START(1) do - for idx = 1, 800 do + for idx = 1, 500 do srcs[idx](i) end end end) --- todo: optimize, repeat with batching -ROOT_BENCH("update 1000x 1->1 common extern. graph", function() +ROOT_BENCH("update 1000->1 graph (batched)", 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 + batch(function() + for idx = 1, 1000 do + srcs[idx](i) + end + end) + end +end) + +-- todo: optimize this case +ROOT_BENCH("update 1000 1->1 common extern. graph", function() local ext = source(-1) local srcs = {} diff --git a/test/tests.luau b/test/tests.luau index 5c51e5f..16fa50d 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1800,6 +1800,49 @@ TEST("changed()", wrap_root(function() end end)) +TEST("batch()", wrap_root(function() + local source = vide.source + local derive = vide.derive + local batch = vide.batch + + do CASE "child evaluation halted" + local a = source(0) + + local count = { b = 0, b2 = 0, c = 0 } + + local b = derive(function() + count.b += 1 + return a() + 1 + end) + + local b2 = derive(function() + count.b2 += 1 + return a() + 2 + end) + + local c = derive(function() + count.c += 1 + return b() + b2() + end) + + batch(function() + a(1) + CHECK(count.b == 1) + CHECK(count.b2 == 1) + CHECK(count.c == 1) + end) + + CHECK(count.b == 2) + CHECK(count.b2 == 2) + CHECK(count.c == 2) + + CHECK(b() == 2) + CHECK(c() == 5) + end + + -- todo: test batch call in recursive set +end)) + TEST("read()", wrap_root(function() local source = vide.source local effect = vide.effect From 65fe3fcf47547cbfe4960d276b59601e1f57245e Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 16 Nov 2023 18:01:45 +0000 Subject: [PATCH 48/56] Fix some graph edge cases --- CHANGELOG.md | 14 ++++++++- src/graph.luau | 25 ++++++--------- test/tests.luau | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558b398..5ea3ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -- +### Added + +- Batched updates with `batch()`. + +### Changed + +- Improved graph updating algorithm. +- Graph nodes no longer destroy children; only owned. + +### Fixed + +- Graph edge case where a destroyed node can be readded if it was queued for + evaluation before being destroyed. -------------------------------------------------------------------------------- diff --git a/src/graph.luau b/src/graph.luau index e32f3d0..9288a82 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -99,27 +99,16 @@ end local function find_and_swap_pop(t: { T }, v: T) local idx = table.find(t, v) :: number - --assert(idx, "value not found") local n = #t t[idx] = t[n] t[n] = nil end -local function remove_child(parent: StartNode, child: Node) - find_and_swap_pop(parent, child) -end - -local function disown(node: Node) - if node.owner then - find_and_swap_pop(node.owner.owned :: { Node }, node) - end -end - local function unparent(node: Node) local parents = node.parents for i, parent in next, parents do - remove_child(parent, node) + find_and_swap_pop(parent, node) parents[i] = nil end end @@ -127,13 +116,16 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) - disown(node) + + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) + node.owner = false + end if node.owned then local owned = node.owned while owned[1] do destroy(owned[1]) end end - while node[1] do destroy(node[1]) end end local function destroy_owned(node: Node) @@ -202,7 +194,7 @@ local function flush_update_queue() local node = update_queue[i] --assert(node.effect) - if evaluate_node(node) then + if node.owner and evaluate_node(node) then queue_children(node) end @@ -224,7 +216,8 @@ local function update(root: StartNode) local node = update_queue[i] --assert(node.effect) - if evaluate_node(node) then + -- check if node is still owned in case destroyed after queued + if node.owner and evaluate_node(node) then queue_children(node) end diff --git a/test/tests.luau b/test/tests.luau index 16fa50d..9cd6afe 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1923,6 +1923,7 @@ TEST("graph edge cases", wrap_root(function() local source = vide.source local derive = vide.derive local effect = vide.effect + local root = vide.root do CASE "diamond A,B,C,D" --[[ @@ -2006,6 +2007,89 @@ TEST("graph edge cases", wrap_root(function() CHECK(b() == 2) CHECK(count == 2) end + + do CASE "do not destroy children" + local parent = source(0) + + local + destroy, + parent_to_destroy, + update_parent_to_destroy + = root(function(destroy) + local src = source(0) + return + destroy, + derive(function() return src() end), + src + end) + + local count = 0 + + effect(function() + count += 1 + parent() + parent_to_destroy() + end) + + parent(parent() + 1) + CHECK(count == 2) + update_parent_to_destroy(1) + CHECK(count == 3) + + destroy() + + update_parent_to_destroy(2) + CHECK(count == 3) + + parent(parent() + 1) + CHECK(count == 4) + end + + do CASE "double destroy" + -- issue: + -- parent evaluates + -- child A queued + -- child B queued + -- child A destroys child B + -- child B reevaluates due to already being queued + -- parent destroys, destroys child B - uh oh + + local + destroy_parent, + parent, + update_parent + = root(function(destroy) + local src = source(0) + return + destroy, + derive(function() return src() end), + src + end) + + local destroy_child, _child_B = function() end, nil + + local count_A = 0 + + -- child_A + effect(function() + count_A += 1 + parent() + destroy_child() + end) + + local count_B = 0 + destroy_child, _child_B = root(function(destroy) + return + destroy, + derive(function() count_B += 1; return parent() end) + end) + + update_parent(parent() + 1) + CHECK(count_A == 2) + CHECK(count_B == 1) -- child B should not run again + destroy_parent() -- should not error + CHECK(true) + end end)) TEST("strict", wrap_root(function() From 8eb5f96c5b47406798a2f13b62aee84eb98821dd Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:27:56 +0000 Subject: [PATCH 49/56] Fix recursive `batch()` use --- docs/api/reactivity-utility.md | 18 ++++++++++++++ src/batch.luau | 14 ++++++----- src/graph.luau | 7 +++++- test/tests.luau | 44 ++++++++++++++++++++++++++++++++-- todo.md | 8 +------ 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index a16e251..ee82067 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -73,4 +73,22 @@ read can still be tracked inside a reactive scope. function read(value: T | () -> T): T ``` +## batch() + +Runs a given function where any source updates made within the function do not +trigger effects until after the function runs. + +- **Type** + + ```lua + function batch(fn: () -> ()) + ``` + +- **Details** + + Improves performance when an effect depends on multiple sources, and those + sources need to be updated. Updating those sources inside a batch call will + only cause the effect to run once after the batch call ends instead of after + each time a source is updated. + -------------------------------------------------------------------------------- diff --git a/src/batch.luau b/src/batch.luau index 3c08127..1951789 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -11,13 +11,15 @@ local function batch(setter: () -> ()) local ok, err: string? = pcall(setter) - flags.batch = false - - if not ok then throw(`error occured while batching updates: {err}`) end - - if not already_batching then -- todo: flush anyways? - graph.flush_update_queue() + if not already_batching then + flags.batch = false + + if not already_batching then + graph.flush_update_queue() + end end + + if not ok then throw(`error occured while batching updates: {err}`) end end return batch diff --git a/src/graph.luau b/src/graph.luau index 9288a82..66587a8 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -185,8 +185,11 @@ local function queue_children(node: StartNode) update_queue.n = i end +local _flushing = false local function flush_update_queue() - -- todo: test with recursive batch sets + assert(not flushing, "recursive queue flush occured") -- todo + _flushing = true + local n0 = 0 local i = n0 + 1 @@ -203,6 +206,8 @@ local function flush_update_queue() end update_queue.n = n0 + + _flushing = false end local function update(root: StartNode) diff --git a/test/tests.luau b/test/tests.luau index 9cd6afe..94c7bb4 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1805,7 +1805,7 @@ TEST("batch()", wrap_root(function() local derive = vide.derive local batch = vide.batch - do CASE "child evaluation halted" + do CASE "evaluation deferred" local a = source(0) local count = { b = 0, b2 = 0, c = 0 } @@ -1840,7 +1840,47 @@ TEST("batch()", wrap_root(function() CHECK(c() == 5) end - -- todo: test batch call in recursive set + do CASE "recursive call" + local a1 = source(0) + local a2 = source(0) + local a3 = source(0) + + local count = { b1 = 0, b2 = 0, b3 = 0 } + + local b1 = derive(function() + count.b1 += 1 + return a1() + 1 + end) + + local b2 = derive(function() + count.b2 += 1 + return a2() + 1 + end) + + local b3 = derive(function() + count.b3 += 1 + return a3() + 1 + end) + + batch(function() + a1(1) + batch(function() + a2(2) + end) + a3(3) + CHECK(count.b1 == 1) + CHECK(count.b2 == 1) + CHECK(count.b3 == 1) + end) + + CHECK(count.b1 == 2) + CHECK(count.b2 == 2) + CHECK(count.b3 == 2) + + CHECK(b1() == 2) + CHECK(b2() == 3) + CHECK(b3() == 4) + end end)) TEST("read()", wrap_root(function() diff --git a/todo.md b/todo.md index 1256cdd..6f254bd 100644 --- a/todo.md +++ b/todo.md @@ -1,10 +1,4 @@ # todo -- optimize wide graph updating -- implement from solid: - - stores - - portals - - batch() -- optimize `indexes()` double-diffing -- cleanup source and tests +- improve error traces - prevent redundant re-eval of nodes in a complex diamond graph From c288cb92c4881608e38585ddfc472a8959c15bea Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:53:30 +0000 Subject: [PATCH 50/56] Minor refactors Also fixed potential bug with `create()` being called recursively if a property binding passed as a property to `create()` also calls `create()` --- src/action.luau | 6 +- src/apply.luau | 148 ++++++++++++++++++++++++++--------------------- src/bind.luau | 5 +- src/create.luau | 50 +++++++++------- src/derive.luau | 4 +- src/effect.luau | 4 +- src/graph.luau | 6 +- src/init.luau | 5 +- src/maps.luau | 6 +- src/memoize.luau | 17 ------ src/read.luau | 7 +++ src/source.luau | 2 +- src/spring.luau | 4 +- src/switch.luau | 6 +- src/throw.luau | 2 +- test/tests.luau | 27 +++++++++ 16 files changed, 169 insertions(+), 130 deletions(-) delete mode 100644 src/memoize.luau create mode 100644 src/read.luau diff --git a/src/action.luau b/src/action.luau index f40bf3a..8cc4987 100644 --- a/src/action.luau +++ b/src/action.luau @@ -10,14 +10,14 @@ local function is_action(v: any) end local function action(callback: (Instance) -> (), priority: number?): Action - local t = { + local a = { priority = priority or 1, callback = callback } - setmetatable(t :: any, ActionMT) + setmetatable(a :: any, ActionMT) - return table.freeze(t) + return table.freeze(a) end return function() diff --git a/src/apply.luau b/src/apply.luau index 1cc533a..13e8cab 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -13,41 +13,58 @@ type Node = graph.Node type Array = { V } type Map = { [K]: V } --- buffer of event -> callback to connect after properties are set -local event_buffer = {} :: Map ()> +local free_caches: { + -- event listeners to connect after properties are set + events: Map< + string, -- event name + () -> () -- listener + >, --- buffer of priority -> callback to run after events are connected -local action_buffers = {} :: Map ()>> + -- actions to run after events are connected + actions: Map< + number, -- priority + Array<(Instance) -> ()> -- action callbacks + >, --- lazily create buffers on nil index -setmetatable(action_buffers :: any, { - __index = function(_, i: number) - action_buffers[i] = {} - return action_buffers[i] + -- cache to detect duplicate property setting at same nesting depth + nested_debug: Map< + number, -- depth + Map -- set of property names + >, + + -- use stack instead of recursive function to process nesting layers one at time + -- deeper-nested properties take precedence over shallower-nested ones + -- each nested layer occupies two indexes: 1. table ref 2. nested depth + -- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } + nested_stack: { {} | number } +}? + +local function borrow_caches(): typeof(assert(free_caches)) + if free_caches then + local caches = free_caches :: typeof(assert(free_caches)) + free_caches = nil + return caches + else + return { + events = {}, + actions = setmetatable({} :: any, { -- lazy init + __index = function(self, i) self[i] = {}; return self[i] end + }), + nested_debug = setmetatable({} :: any, { + __index = function(self, i: number) self[i] = {}; return self[i] end + }), + nested_stack = {} + } end -}) +end --- cache in strict mode to detect duplicate property set at same nesting level -local nested_debug_cache = {} :: Map> +local function return_caches(caches: typeof(free_caches) ) + free_caches = caches +end -setmetatable(nested_debug_cache :: any, { - __index = function(_, i: number) - nested_debug_cache[i] = {} - return nested_debug_cache[i] - end -}) - --- use stack instead of recursive function to process nested layers one at time --- deeper-nested properties take precedence over shallower-nested ones --- each nested layer occupies two indexes: 1. table ref 2. nested depth --- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } -local nested_stack = {} :: { {} | number } - --- todo: solution without manual updating of this table -- map of datatype names to class default constructor for aggregate init local aggregates = {} - -for i, v in next, { +for name, class in { CFrame = CFrame, Color3 = Color3, UDim = UDim, @@ -55,27 +72,39 @@ for i, v in next, { Vector2 = Vector2, Vector3 = Vector3, Rect = Rect -} do - aggregates[i] = v.new +} :: Map do + aggregates[name] = class.new end --- processes a potentially nested table of values to assign to an instance -local function process_props(instance: Instance, properties: Map) +-- applies table of nested properties to an instance using full vide semantics +local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T + if not properties then + throw("attempt to call a constructor returned by create() with no properties") + end + local strict = flags.strict - table.clear(nested_stack) - if strict then table.clear(nested_debug_cache) end + -- queue parent assignment if any for last + local parent: unknown = properties.Parent + local caches = borrow_caches() + local events = caches.events + local actions = caches.actions + local nested_debug = caches.nested_debug + local nested_stack = caches.nested_stack + + -- process all properties local depth = 1 - repeat for property, value in properties do + if property == "Parent" then continue end + if type(property) == "string" then - if strict then -- check for duplicate prop assignment at nesting layer - if nested_debug_cache[depth][property] then + if strict then -- check for duplicate prop assignment at nesting depth + if nested_debug[depth][property] then throw(`duplicate property {property} at depth {depth}`) end - nested_debug_cache[depth][property] = true + nested_debug[depth][property] = true end if type(value) == "table" then -- attempt aggregate init @@ -86,7 +115,7 @@ local function process_props(instance: Instance, properties: Map () -- add event to buffer + events[property] = value :: () -> () -- add event to buffer else bind.property(instance, property, value :: () -> ()) -- bind property end @@ -98,7 +127,7 @@ local function process_props(instance: Instance, properties: Map Instance | Array) -- bind children elseif type(value) == "table" then if is_action(value) then - table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer else table.insert(nested_stack, value :: {}) table.insert(nested_stack, depth + 1) -- push table to stack for later processing @@ -109,40 +138,17 @@ local function process_props(instance: Instance, properties: Map(instance: T & Instance, properties: { [unknown]: unknown }): T - if not properties then - throw("no properties given, did you forget to call the constructor returned by create()?") + for event, listener in next, events do + (instance :: any)[event]:Connect(listener) end - -- queue parent assignment if any for last - local parent: unknown = properties.Parent - if parent then properties.Parent = nil end - - -- reset buffers - table.clear(event_buffer) - for _, buffer in next, action_buffers do - table.clear(buffer) - end - - -- process all properties for immediate setting or buffering - process_props(instance, properties) - - -- connect buffered events - for event, fn in next, event_buffer do - (instance :: any)[event]:Connect(fn) - end - - -- run buffered actions - for _, buffer in next, action_buffers do - for _, callback in next, buffer do + for _, queued in next, actions do + for _, callback in next, queued do callback(instance) end end @@ -156,6 +162,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end end + -- clear caches + table.clear(events) + for _, queued in next, actions do table.clear(queued) end + if strict then table.clear(nested_debug) end + table.clear(nested_stack) + + return_caches(caches) + return instance end diff --git a/src/bind.luau b/src/bind.luau index c614590..4a204f1 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -5,7 +5,7 @@ local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner @@ -31,8 +31,7 @@ function create_binding(updater: (T) -> T, binding: T) end end - - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(binding, updater) diff --git a/src/create.luau b/src/create.luau index 0ce889e..7adfc68 100644 --- a/src/create.luau +++ b/src/create.luau @@ -5,41 +5,51 @@ local Instance = game and Instance or require "test/mock".Instance :: never local throw = require(script.Parent.throw) local defaults = require(script.Parent.defaults) local apply = require(script.Parent.apply) -local memoize = require(script.Parent.memoize) + +local ctor_cache = {} :: { [string]: () -> Instance } + +setmetatable(ctor_cache :: any, { + __index = function(self, class) + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then throw(`invalid class name, could not create instance of class { class }`) end + + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in next, default do + (instance :: any)[i] = v + end + end + + local function ctor(properties: Props): Instance + return apply(instance:Clone(), properties) + end + + self[class] = ctor + return ctor + end +}) local function create_instance(class: string) - local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then throw(`invalid class name, could not create instance of class { class }`) end - - local default: { [string]: unknown }? = defaults[class] - if default then - for i, v in next, default do - (instance :: any)[i] = v - end - end - - return function(properties: { [any]: unknown }): Instance - return apply(instance:Clone(), properties) - end -end; create_instance = memoize(create_instance) -- always return same constructor for given class + return ctor_cache[class] +end local function clone_instance(instance: Instance) - return function(properties: { [any]: unknown }): Instance + return function(properties: Props): Instance local clone = instance:Clone() - if not clone then error("Attempt to clone a non-archivable instance", 3) end + if not clone then throw "attempt to clone a non-archivable instance" end return apply(clone, properties) end end -local function create(class_or_instance: string|Instance) +local function create(class_or_instance: string|Instance): (Props) -> Instance if type(class_or_instance) == "string" then return create_instance(class_or_instance) elseif typeof(class_or_instance) == "Instance" then return clone_instance(class_or_instance) else - throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance)) + throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) + return nil :: never end - return nil :: never end type Props = { [any]: any } diff --git a/src/derive.luau b/src/derive.luau index 49094d5..863fd98 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -4,11 +4,11 @@ local graph = require(script.Parent.graph) local create_node = graph.create_node local set_owner = graph.set_owner local track = graph.track -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local function derive(source: () -> T): () -> T - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(false :: any, source) diff --git a/src/effect.luau b/src/effect.luau index 43b12ab..bbe1669 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -2,12 +2,12 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) local create_node = graph.create_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner local function effect(callback: (T) -> T, initial_value: T) - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(initial_value, callback) diff --git a/src/graph.luau b/src/graph.luau index 66587a8..ecda1ab 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -40,7 +40,7 @@ local function get_scope(): Node? return scopes[scopes.n] end -local function get_owning_scope(): Node +local function assert_owning_scope(): Node local scope = get_scope() if not scope then @@ -187,7 +187,7 @@ end local _flushing = false local function flush_update_queue() - assert(not flushing, "recursive queue flush occured") -- todo + assert(not _flushing, "recursive queue flush occured") -- todo _flushing = true local n0 = 0 @@ -266,7 +266,7 @@ return table.freeze { close_scope = close_scope, evaluate_node = evaluate_node, get_scope = get_scope, - get_owning_scope = get_owning_scope, + assert_owning_scope = assert_owning_scope, add_cleanup = add_cleanup, set_owner = set_owner, destroy = destroy, diff --git a/src/init.luau b/src/init.luau index cfc49d0..17812c3 100644 --- a/src/init.luau +++ b/src/init.luau @@ -14,6 +14,7 @@ local effect = require(script.effect) local derive = require(script.derive) local cleanup = require(script.cleanup) local untrack = require(script.untrack) +local read = require(script.read) local batch = require(script.batch) local switch = require(script.switch) local show = require(script.show) @@ -60,10 +61,8 @@ local vide = { -- util cleanup = cleanup, untrack = untrack, + read = read, batch = batch, - read = function(value: T | () -> T): T - return if type(value) == "function" then value() else value - end, -- animations spring = spring, diff --git a/src/maps.luau b/src/maps.luau index 1034e9a..ef36209 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -10,7 +10,7 @@ local create_start_node = graph.create_start_node local set_owner = graph.set_owner local track = graph.track local update = graph.update -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local open_scope = graph.open_scope local close_scope = graph.close_scope local evaluate_node = graph.evaluate_node @@ -28,7 +28,7 @@ local function check_primitives(t: {}) end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = get_owning_scope() + local owner = assert_owning_scope() local subowner = create_node(false, false) set_owner(subowner, owner) @@ -123,7 +123,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = get_owning_scope() + local owner = assert_owning_scope() local subowner = create_node(false, false) set_owner(subowner, owner) diff --git a/src/memoize.luau b/src/memoize.luau deleted file mode 100644 index cf83427..0000000 --- a/src/memoize.luau +++ /dev/null @@ -1,17 +0,0 @@ -local function memoize(f: (X) -> Y): (X) -> Y - local cache: { [X]: Y? } = {} - - return function(x: X): Y - local y = cache[x] - - if not y then - y = f(x) - cache[x] = y - end - - return y :: Y - end -end - -return memoize - diff --git a/src/read.luau b/src/read.luau new file mode 100644 index 0000000..d3a2fb7 --- /dev/null +++ b/src/read.luau @@ -0,0 +1,7 @@ +if not game then script = require "test/relative-string" end + +local function read(value: T | () -> T): T + return if type(value) == "function" then value() else value +end + +return read diff --git a/src/source.luau b/src/source.luau index f1307fc..dd633bb 100644 --- a/src/source.luau +++ b/src/source.luau @@ -6,7 +6,7 @@ local create_start_node = graph.create_start_node local track = graph.track local update = graph.update -export type Source = (() -> T) & ((T) -> T) +export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source local node = create_start_node(initial_value) diff --git a/src/spring.luau b/src/spring.luau index b627a74..91f17b1 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -27,7 +27,7 @@ type Node = graph.Node type StartNode = graph.StartNode local create_node = graph.create_node local create_start_node = graph.create_start_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local update = graph.update local set_owner = graph.set_owner @@ -150,7 +150,7 @@ local springs: { [SpringData]: StartNode } = {} setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local owner = get_owning_scope() + local owner = assert_owning_scope() -- https://en.wikipedia.org/wiki/Damping diff --git a/src/switch.luau b/src/switch.luau index 1ddc8e6..12fd376 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -9,14 +9,14 @@ local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner local track = graph.track local destroy = graph.destroy -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local open_scope = graph.open_scope local close_scope = graph.close_scope type Map = { [K]: V } local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = get_owning_scope() + local owner = assert_owning_scope() return function(map) local last_scope: Node? @@ -35,7 +35,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () if component == nil then return nil end if type(component) ~= "function" then - throw("map must map a value to a function") + throw "map must map a value to a function" end local new_scope = create_node(false, false) diff --git a/src/throw.luau b/src/throw.luau index d3ea687..70b7973 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end local trace = require(script.Parent.trace) local function throw(msg): any - error(msg, trace()-1) + error(msg, trace() - 1) end return throw diff --git a/test/tests.luau b/test/tests.luau index 94c7bb4..c38c6a7 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -942,6 +942,33 @@ TEST("create()", wrap_root(function() CHECK(not wref[1]) end + do CASE "recursive create" + local set_test_to_true = vide.action(function(self) (self :: any).test = true end) + + local f2 + + local to_apply = { + { a = 1 }, + set_test_to_true, + b = function() f2 = create "Frame" { a = 2 } end, + } :: { [number|string]: unknown } + + -- do -- confirm iteration order + -- local t = {} + -- for i in to_apply do + -- table.insert(t, i) + -- end + -- assert(t[1] == "a") + -- end + + local f = create "Frame" (to_apply) + + CHECK((f :: any).a == 1) + CHECK((f :: any).test == true ) + + CHECK((f2 :: any).a == 2) + end + do CASE "garbage collection test" local wref From 338c66ed57162063c87b5a61b73950685ea5338e Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 21 Nov 2023 18:48:47 +0000 Subject: [PATCH 51/56] Try improve crash course --- docs/.vitepress/config.ts | 3 +- docs/tut/crash-course/1-introduction.md | 46 ++---- docs/tut/crash-course/10-cleanup.md | 26 +++- docs/tut/crash-course/15-concepts.md | 132 ++++++++++++++++++ docs/tut/crash-course/2-creation.md | 39 +++--- docs/tut/crash-course/3-components.md | 14 +- docs/tut/crash-course/4-source.md | 8 +- docs/tut/crash-course/6-root.md | 16 ++- docs/tut/crash-course/7-stateful-component.md | 7 +- docs/tut/crash-course/8-property-binding.md | 14 +- docs/tut/crash-course/9-derived-source.md | 20 +-- 11 files changed, 223 insertions(+), 102 deletions(-) create mode 100644 docs/tut/crash-course/15-concepts.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7c820ff..0a3500d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -51,7 +51,8 @@ export default withMermaid({ { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, { text: "Actions", link: "/tut/crash-course/13-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" } + { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/15-concepts" } ] }, { diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 604f7ae..261ce83 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -1,45 +1,25 @@ # Introduction -This is a brief tutorial designed to give you a quick run through the usage of -Vide. +This is a tutorial that introduces the concepts and usage of Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). -This tutorial assumes familiarity with Luau and Roblox GUI. +This tutorial assumes familiarity with Luau and Roblox UI. ## Why Vide? -Creating UI is a slow and tedious process. The purpose of Vide is to make UI -declarative and concise, making it faster to create and more importantly easier -to maintain. Vide achieves this using a reactive style of programming which -allows you to focus on the flow of data through your application without -worrying about manually updating UI instances. +Creating UI is complicated, slow, and tedious. + +Vide tries to simplify and speed up this process by providing a declarative and +reactive of style programming, which lets you focus more on designing the UI +itself and not having to manually update or reparent UI instances. Some of the main focuses behind Vide's design choices: -- Concise syntax. -- Being completely typecheckable. -- Independence from instance lifetimes. -- Real reactivity. +- Minimal syntax. +- Complete typechecking +- Independence from instances. -## Structure Of A Vide App - -The entry point for all Vide apps is the `mount()` function. This function -sets up Vide's reactivity system. It takes and calls a function that should -create your entire app, and will apply its result to a target. - -In Vide, your app should be composed of functions, each function creates a -specific part of your app, and can be reused if needed. These functions are -called *components*. - -```lua -local function App() - return { - PlayerStats(), - Inventory(), - Settings() - } -end - -mount(App, game.StarterGui) -``` +As with most declarative libraries, there is an initial learning curve to +understand the concepts and usage. This tutorial tries to comprehensively +cover these concepts and usage, more so than you need just to use it. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index 4f62233..b9361ec 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,8 +2,7 @@ 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. +is used to queue a cleanup callback for the next time a reactive scope re-runs. ```lua local mount = vide.mount @@ -33,18 +32,33 @@ end local unmount = mount(Timer) -unmount() -- all registered cleanups are ran, heartbeat connection stopped +unmount() -- all queued cleanups are ran, heartbeat connection stopped ``` 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. +when the reactive scope responsible for creating the timer component is +destroyed, such as when it is unmounted. + +Vide does not see "components", it only sees reactive scopes and how they are +linked together. Components are just a user pattern that creates UI instances +alongside effects. In other words, instances are just a side-effect of the +reactive graph. When a reactive scope is created, you create a corresponding +instance to display that data, when that reactive scope is destroyed, any +cleanups queued will be ran and take care of anything that needs to be, such +as disconnecting connections. This is another reason why `mount()` is used at the top level of your app, so that any registered cleanups created by your app components can be ran when they are destroyed. +Side note: Roblox instances do not need to be explicitly destroyed for their +memory to be freed, they only need to be parented to `nil`. So there is no +need to use `cleanup()` to destroy instances. However, be wary of connecting +a function that references an instance to an event from the same instance, +this causes the instance to reference itself and never be freed. In such a case +you would need to use `cleanup()` to disconnect this connection or to explicitly +destroy the instance. + The reactive graph for the above example: ```mermaid diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md new file mode 100644 index 0000000..2c2c313 --- /dev/null +++ b/docs/tut/crash-course/15-concepts.md @@ -0,0 +1,132 @@ +# Concepts Summary + +A summary of all the concepts covered during the crash course. + +## Source + +A source of data. + +Stores a single value that can be updated by the user. + +## Effect + +Anything that happens in reponse to a source update. + +Vide has built-in functions to create effects such as + +- `effect()` - runs arbitrary user code on source update +- `derive()` - updates a derived source on source update + +## Reactive Scope + +A scope created by certain Vide functions where source updates can be tracked, +and cleanups queued. + +When a source used inside a reactive scope is updated, the reactive scope will +rerun. + +Reactive scopes are created by functions such as + +- `root()` +- `effect()` +- `derive()` + +## Owner + +A reactive scope created within an outer reactive scope, is *owned* by the outer +reactive scope. + +When a reactive scope is re-ran or destroyed, all reactive scopes owned by it +are also destroyed. + +Vide does not let you create reactive scopes without owners. + +## Root Reactive Scope + +A top-level reactive scope. These scopes are an exception to the owner rule. + +Created by `root()`, which `mount()` uses internally. + +A root reactive scope can be created on its own. It allows other reactive scopes +to be created with an owner. + +Root reactive scopes must be destroyed manually by the user, a function to do +this is given by `root()`. + +A root reactive scope can be created within another reactive scope and it will +not automatically be owned by that scope. + +## Cleanup + +Cleans up the result from an effect. + +Unneeded in most cases, a cleanup is arbitrary code that can be ran before +a reactive scope is rerun or destroyed, so that the result from the previous +run can be cleaned up. A cleanup can be queued by using `cleanup()` within +a reactive scope. + +## Tracking + +Reactive scopes are tracking by default, meaning sources read from within scope +will be tracked. + +A reactive scope can be made temporarily non-tracking within `untrack()`, so +that any source used will be ignored. The only function that creates a +nontracking reactive scope by default is `root()`. + +## Reactive Graph + +The combination of reactive scopes can viewed graphically, called a +*reactive graph*. This can be a more intuitive way to think of the +relationships between effects and the sources they depend on. + +### Code + +```lua +local count = source(0) + +root(function() + local text = derive(function() + return "count: " .. text() + end) + + effect(function() + print(text()) + end) +end) +``` + +### Graph resulting from code + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph LR + +subgraph root + text --> effect +end + +count --> text +``` + +Notes: + +- Since `count` is a source, not an effect, it can exist + outside of a root reactive scope. +- An update to `count` will cause `text` to rerun, which + then causes `effect` to rerun. +- When the root reactive scope is destroyed, `text` and + `effect` will be destroyed alongside it, since they are + owned by it. `count` will be untouched and future updates + to `count` will have no effect. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index e66f987..c1cafc0 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -9,36 +9,31 @@ Luau allows us to omit parentheses `()` when calling functions with string or table literals which Vide takes advantage of for brevity. ```lua -local mount = vide.mount local create = vide.create -local function App() - return create "ScreenGui" { - create "Frame" { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromScale(0.4, 0.7), +return create "ScreenGui" { + create "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromScale(0.4, 0.7), - create "TextLabel" { - Text = "hi" - }, + create "TextLabel" { + Text = "hi" + }, - create "TextLabel" { - Text = "bye" - }, + create "TextLabel" { + Text = "bye" + }, - create "TextButton" { - Text = "click me", + create "TextButton" { + Text = "click me", - Activated = function() - print "clicked!" - end - } + Activated = function() + print "clicked!" + end } } -end - -mount(App, game.StarterGui) +} ``` Assign a value to a string key to set a property, and assign a value to a diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index a9f38d9..f6b3ad4 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,8 +1,11 @@ # Components +Vide encourages separating different parts of your UI into functions called +*components*. + A component is a function that creates and returns a piece of UI. -This is a way to separate your app into small chunks that you can reuse and put +This is a way to separate your UI into small chunks that you can reuse and put together. ::: code-group @@ -66,12 +69,15 @@ Above is a simple example of a button component being used across files. A single parameter `props` is used to pass properties to the component. -Components allow you to *encapsulate* behavior. You can only modify the -component in ways that you allow in the component, through the `props` parameter. +You can only modify the component in ways that you allow in the component, +through the `props` parameter. To create a new button all you must do is call the `Button` function, passing in values. This saves having to create and set every property each time. Also, when updating the button component in future, any changes to the button file will be seen anywhere the button is used in your app. -This can be extended to much more complicated UI. +The `mount()` function is used to set up Vide's reactivity system when creating +your UI. It only needs to be called once at the top-level with the function that +puts together your entire app. It also parents the returned instance to another +a target instance for you. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index f5a848a..c356b6b 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -23,9 +23,6 @@ count(count() + 1) -- increment count by 1 Sources can be *derived* by wrapping them in functions. A wrapped source effectively becomes a new source. -Derived sources should be pure functions. This is where the same output is -always produced for the same input no matter how many times it is reran. - ```lua local count = source(0) @@ -40,6 +37,5 @@ print(text()) -- "count: 1" Sources on their own aren't very special, the above can be achieved with plain variables. The real use for sources become apparent when used in combination -with Vide's *reactive scopes*. When a source is read from within a reactive -scope, it can automatically rerun the scope that reads it when the source is -updated in the future. +with *effects*. Similar to a signal and connection, a source and effect allows +you to do things like automatically updating UI when a source is updated. diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md index 2b2d7ad..abeec2f 100644 --- a/docs/tut/crash-course/6-root.md +++ b/docs/tut/crash-course/6-root.md @@ -2,9 +2,9 @@ Any reactive scopes created, such as by `effect()`, must be done so within a "root" reactive scope. This is the main purpose of `mount()`, which you use -once at the top level to create your app as shown in the first introduction. +once at the top level to create your UI. -This is so that when the app is unmounted, it can clean up any reactive scopes +This is so that if you want to destroy your UI, it can stop any reactive scopes created within it, since reactive scopes track any reactive scopes created within them. @@ -53,16 +53,22 @@ The reactive graph for the above example looks like so: graph -subgraph root["mount"] +subgraph root direction LR count --> effect end ``` -When the `mount` scope is destroyed, the `effect` scope will also be destroyed -since it was created within it. +When the root reactive scope created by `mount()` is destroyed, the `effect` +scope will also be destroyed since it was created within it. + +This is important because you may have an effect that updates the property of a +UI instance, meaning the effect is referencing and holding that instance in +memory. The effect being destroyed will remove this reference, allowing the +instance to be garbage collected. You don't need to worry about ensuring all your effects are created within a root scope, since you should be creating all your UI and corresponding effects within a top-level `mount()` call that puts all your UI together. So it is safe to assume that any effect you create will be created under this top level scope. +Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 8de8209..12f16a2 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,6 +1,6 @@ # Stateful Components -A stateful component is a component that stores and displays some data. +A stateful component is a component that can update in reponse to data. Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. @@ -32,9 +32,6 @@ end Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. -Making a property update based on a source is also referred to as *property -binding*. - Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the scope of the component. @@ -70,4 +67,4 @@ count(1) -- the Counter component will update to display this count Sources can be created internally or passed in from externally, there are no restrictions on how they are used as long as the effect using it is created -within a reactive scope so that it can be cleaned up later. +within a reactive scope. diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md index 8304565..a981b8a 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -25,17 +25,17 @@ end This example is equivalent to the example seen on the previous page. -Instead of explicitly creating an effect, assigning a (non-event) property -a function will implicitly create a side-effect to update that property anytime -a dependent source is updated. +Instead of explicitly creating an effect, assigning a (non-event) property a +function will implicitly create an effect to update that property anytime a +source used within is updated. Just like effects, the function is ran immediately in a reactive scope to set -the property initially and determine what sources are being depended on. +the property initially and determine what sources are being used. This allows you as the programmer to not need to manually update UI as the state -of your program changes. You just define how the data sources map to UI, and -Vide's reactive system will automatically update any properties depending on -those sources that were updated. +of your program changes. You just define how data sources map to UI, and Vide's +reactive system will automatically update any properties depending on those +sources. ## Children Binding diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 17e3b77..d3b7156 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -29,13 +29,10 @@ local text = function() return "count: " .. tostring(count()) end -effect(function() - text() -- prints "ran" -end) +effect(function() text() end) +effect(function() text() end) -effect(function() - text() -- prints "ran" again -end) +source(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will @@ -55,16 +52,13 @@ local text = derive(function() return "count: " .. tostring(count()) end) -effect(function() - text() -- prints "ran" -end) +effect(function() text() end) +effect(function() text() end) -effect(function() - text() -- does not print, returns cached value -end) +source(1) -- prints "ran" x1 ``` -`derive()` must also be used within a root reactive scope, just like `effect()`. +`derive()` must also be called within a reactive scope, just like `effect()`. If the recalculated value is the same as the old value, the derived source will not rerun the effects using it. From c527a62ab1dc04be03cabc7e69ae2d401471972c Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:14:00 +0000 Subject: [PATCH 52/56] Bump version to `0.2.0` --- CHANGELOG.md | 7 ++++--- src/init.luau | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea3ffa..83d16e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## Unreleased +## [0.2.0] - 2023-11-22 ### Added @@ -15,12 +15,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Improved graph updating algorithm. -- Graph nodes no longer destroy children; only owned. +- Graph nodes when destroyed no longer destroy children; only owned. ### Fixed - Graph edge case where a destroyed node can be readded if it was queued for - evaluation before being destroyed. + rerun before being destroyed. +- Some properties not being applied when `create()` is used recursively. -------------------------------------------------------------------------------- diff --git a/src/init.luau b/src/init.luau index 17812c3..840f7f9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- vide.luau --- v0.1.1 +-- v0.2.0 -------------------------------------------------------------------------------- if not game then script = require "test/relative-string" end From 50244c2bd8bf21f8bf031970cfa9d7a99dc6c727 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:34:02 +0000 Subject: [PATCH 53/56] Update wally version --- wally.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wally.toml b/wally.toml index d4d48ed..b1387e5 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.1.1" +version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] From 3aed45212a1bb96ce1d8ba18649fc6482d02f94f Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Fri, 24 Nov 2023 11:09:32 +0000 Subject: [PATCH 54/56] Fix site mermaid rendering Vitepress rc 26 breaks mermaid plugin - use rc 25. --- docs/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/package.json b/docs/package.json index e156bea..5dce993 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { - "vitepress": "^1.0.0-rc.4", - "vitepress-plugin-mermaid": "^2.0.14" + "vitepress": "1.0.0-rc.25", + "vitepress-plugin-mermaid": "2.0.14" } } From d682161c06229f9cb280041fe817680456ad9784 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 7 Dec 2023 17:26:01 +0000 Subject: [PATCH 55/56] Update docs --- docs/.vitepress/config.ts | 2 +- docs/tut/advanced/nested-scoping.md | 188 +++++++++++++ docs/tut/advanced/reactive-scoping.md | 253 ------------------ docs/tut/crash-course/10-cleanup.md | 45 +--- docs/tut/crash-course/11-control-flow.md | 166 ++++-------- docs/tut/crash-course/15-concepts.md | 75 +++--- docs/tut/crash-course/2-creation.md | 5 +- docs/tut/crash-course/3-components.md | 8 +- docs/tut/crash-course/5-effect.md | 8 +- docs/tut/crash-course/6-root.md | 32 ++- docs/tut/crash-course/7-stateful-component.md | 9 +- docs/tut/crash-course/8-property-binding.md | 13 +- src/graph.luau | 4 +- 13 files changed, 315 insertions(+), 493 deletions(-) create mode 100644 docs/tut/advanced/nested-scoping.md delete mode 100644 docs/tut/advanced/reactive-scoping.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0a3500d..87dada1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -58,7 +58,7 @@ export default withMermaid({ { text: "Advanced Reactivity", items: [ - { text: "Reactive Scopes", link: "/tut/advanced/reactive-scoping.md"} + { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} ] } ], diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md new file mode 100644 index 0000000..17c95d2 --- /dev/null +++ b/docs/tut/advanced/nested-scoping.md @@ -0,0 +1,188 @@ +# Nested Reactive Scopes + +Nesting reactive scopes gives you finer control over the reactive graph, but +needs more work to do. The built-in control flow functions try to cover the +most common cases, but they do not cover all of them. + +This tutorial will demonstrate how to implement a `show()` control flow function +using just sources and effects. + +```lua +local mount = vide.mount +local source = vide.source +local show = vide.show + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = count, + Activated = function() count(count() + 1) end + } +end + +mount(function() + local toggled = source(true) + + show(toggled, Button) +end) +``` + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph mount + direction LR + toggle --> show + + subgraph show[show effect] + text[Text effect] + end +end +``` + +Above is the reactive graph for `show()`. It creates a new effect depending on +`toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The +`show` effect calls `Counter`, which creates a new reactive scope to update its +text whenever `count` changes. As per the rules of reactive scopes, a reactive +scope rerunning will destroy any reactive scope created within it. So the text +effect's reactive scope is destroyed whenever the show effect is rerun. + +The same can be achieved without the use of `show()`: + +```lua +local mount = vide.mount +local source = vide.source +local effect = vide.effect +local cleanup = vide.cleanup + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = count, + Activated = function() count(count() + 1) end + } +end + +mount(function() + local toggled = source(true) + + effect(function() + if toggled() then + local destroy = mount(Button) + cleanup(destroy) + end + end) +end) +``` + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph mount + direction LR + toggle --> effect + + subgraph effect + subgraph mount2[inner mount] + text[Text effect] + end + end +end +``` + +This is another way to achieve the same. Here we use `mount()` within the effect +to manually create and destroy a new reactive scope whenever the effect reruns. + +Alternatively, instead of using `mount()`, a new reactive scope can be created +directly within the effect: + +```lua +local mount = vide.mount +local source = vide.source +local effect = vide.effect +local untrack = vide.untrack + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = count, + Activated = function() count(count() + 1) end + } +end + +mount(function() + local toggled = source(true) + + effect(function() + if toggled() then + untrack(Button) + end + end) +end) +``` + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph mount + direction LR + toggle --> effect + + subgraph effect + text[Text effect] + end +end +``` + +Without the use of `untrack()`, an error would occur, since Vide does not allow +the creation of reactive scopes inside reactive scopes that are tracking. The +reason for this, is because if the `Counter` component reads from a source +internally, that can cause the reactive scope calling `Counter()` to track that +source, causing unintentional reruns. As a guard against this, you are forced to +use `untrack()` to create nested reactive scopes. + +The final result is the same as using the `show()` component. An effect is +created which creates the counter, which creates its own reactive scope. The +effect rerunning causes the counter's internal reactive scope to be destroyed, +making sure everything is cleaned up. + + diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md deleted file mode 100644 index 20872cf..0000000 --- a/docs/tut/advanced/reactive-scoping.md +++ /dev/null @@ -1,253 +0,0 @@ -# Reactive Scoping - -This is a brief document designed to give the user more insight into how Vide's -reactive system works. - -## Graph Basics - -Vide's reactivity can be represented as a graph, where each source, derived -source, and effect is a node on that graph. The term "*reactive scope*" is just -an abstraction used to refer to these nodes. Each node is a reactive scope. - -Each node stores a cached value, a side-effect function, cleanup functions, -its parents and children, and its owner and owned. - -Whenever a node is updated it will: - -1. destroy its owned nodes -2. run its cleanups -3. rerun its side-effect and update its cached value -4. if its cached value changes, update its children recursively. - -There is a difference between children nodes and owned nodes: - -- children nodes are updated when a parent is updated. -- owned nodes are destroyed when a parent is updated. -- both children and owned are destroyed when a parent is destroyed. - -Nodes created by `root()` generally have no children, and only tracks owned. -Nodes created by `derive()` generally have no owned, and only tracks children. - -## Basic Example - -```lua -root(function() - local forename = source "quan" - local surname = source "xi" - - local name = derive(function() - return forename() .. " " .. surname() - end) - - effect(function() - print("new name: " .. name()) - end) -end) -``` - -This code will produce a graph that looks like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - subgraph root - forename & surname --> name - name --> effect - end -``` - -Nodes connected by arrows represent parent and children connections. -Nodes within other nodes represent owner and owned connections. - -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. - -When the side-effect for a node is being reran when a node is updated, any -other nodes read within that side-effect are set as parents of the node -currently being reran. As those nodes are read, we know that the current node -depends on them, so any time those nodes are updated, they will update dependent -nodes since they will be stored as children. - -When destroying a node, its descendents are traversed and also destroyed. -When being destroyed, a node's connections (parents and children, owner and -owned) 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 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. - -Nodes created by `source()` can actually exist outside of root nodes, since -they do not have direct side-effects or cleanups, they do not have to be -explicitly destroyed. - -## Control-flow Graph Example - -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(props: { text: string }) - local count = source(0) - - local connection = stepped:Connect(function() count(count() + 1) end) - - cleanup(function() connection:Disconnect() end) - - return create "TextLabel" { - Text = function() - return props.text() .. ": " .. count() - end - } -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. - -```lua -root(function() - local counters = { "A", "B" } - - indexes(counters, function(name) - return Counter { text = name } - end) -end) -``` - -This code produces a graph like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#fff" - } -}}%% - -graph LR - subgraph root - counters --> indexes - - subgraph root1[subroot 1] - n1[name] --> p1[prop binding] - end - - subgraph root2[subroot 2] - n2[name] --> p2[prop binding] - end - end - - indexes .-> root1 & root2 -``` - -This shows how the `indexes()` control flow function creates and manages new -root scopes. The function creates an effect seen as `indexes` in the graph, -which manages the new roots `subroot 1` and `subroot 2`, as well as the sources -`name` for which one exists for each index value in the input table. - -When the input table changes, `indexes()` can automatically destroy and create -subroots based on the changed indexes. Destroyed nodes run any cleanups made, in -this case it is the cleanups to disconnect the counters connection. The same -applies to all other control flow functions. - -Whenever the root reactive scope is destroyed, all its children, `counters` and -`indexes` will be destroyed too, which means that `indexes` children, the -subroots, will also be destroyed. Everything is nicely cleaned up. - -## Custom Control-flow Example - -Below is a simple example of the `show()` control-flow function. - -Each time `visible` changes, `show()` will destroy the current reactive scope -and rerun its function in a new one. - -```lua -local visible = source(true) -local count = source(0) - -root(function() - show(visible, function() - return create "TextLabel" { Text = count } - end) -end) -``` - -The above code produces a graph like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1B1B1F" - } -}}%% - -graph LR - subgraph root - direction LR - show - - subgraph subroot["show() subroot"] - p1[prop binding] - end - end - - visible --> show - count --> p1 - show -.- subroot -``` - -This can be recreated without the `show()` control-flow function, with the -following code: - -```lua -local visible = source(true) -local count = source(0) - -root(function() - local output = derive(function() - visible() - - -- untrack so any source read from within this scope - -- will not cause the outer `derive()` call to rerun, - -- we only want `derive()` to rerun when `visible` changes - return untrack(function() - local label = create "TextLabel" {} - - effect(function() - label.Text = count() - end) - - return label - end) - end) -end) -``` - -Both of the above code samples will produce the same visible result. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index b9361ec..bf632f5 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,7 +2,8 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which -is used to queue a cleanup callback for the next time a reactive scope re-runs. +is used to queue a cleanup callback for the next time a reactive scope is rerun +or destroyed. ```lua local mount = vide.mount @@ -32,53 +33,19 @@ end local unmount = mount(Timer) -unmount() -- all queued cleanups are ran, heartbeat connection stopped +unmount() -- all queued cleanups are ran, heartbeat connection disconnected ``` In the above example, this allows us to disconnect the heartbeat connection when the reactive scope responsible for creating the timer component is destroyed, such as when it is unmounted. -Vide does not see "components", it only sees reactive scopes and how they are -linked together. Components are just a user pattern that creates UI instances -alongside effects. In other words, instances are just a side-effect of the -reactive graph. When a reactive scope is created, you create a corresponding -instance to display that data, when that reactive scope is destroyed, any -cleanups queued will be ran and take care of anything that needs to be, such -as disconnecting connections. - -This is another reason why `mount()` is used at the top level of your app, so -that any registered cleanups created by your app components can be ran when -they are destroyed. - -Side note: Roblox instances do not need to be explicitly destroyed for their +::: tip +Roblox instances do not need to be explicitly destroyed for their memory to be freed, they only need to be parented to `nil`. So there is no need to use `cleanup()` to destroy instances. However, be wary of connecting a function that references an instance to an event from the same instance, this causes the instance to reference itself and never be freed. In such a case you would need to use `cleanup()` to disconnect this connection or to explicitly destroy the instance. - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - -subgraph mount - direction LR - cleanup([cleanup]) ~~~ count - count --> bind["effect (text binding)"] -end -``` +::: diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index e5430b8..91f95ac 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -1,123 +1,57 @@ # Control Flow -Eventually you will need a way to dynamically create and destroy UI elements +Eventually you may need a way to dynamically create and destroy UI elements resulting from source updates. Vide provides functions to help you do this, known as *control flow* functions. These functions return new sources, which hold the instances to be displayed. -These sources can be assigned as children, meaning the displayed children -will update when the input source updates. -Control flow functions are special, because they run their components in a new -reactive scope, which can be destroyed independently of the reactive scope that -called the control flow function itself. This means that parts of your app can -be independently created then destroyed. - -## show() - -The most basic control flow function is `show()`, which is used to conditionally -show a component. - -```lua -local source = vide.source -local show = vide.show - -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Activated = function() joined(true) end - } - end - - return create "Frame" { - show(function() return not joined() end, JoinButton) - } -end -``` - -This will make a button to join if you have not joined already. - -You can also pass a third argument, a fallback to show if the condition is falsey. - -```lua -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Activated = function() joined(true) end - } - end - - local function LeaveButton() - return Button { - Activated = function() joined(false) end - } - end - - return create "Frame" { - show(joined, LeaveButton, JoinButton) - } -end -``` - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root["mount() scope"] - direction LR - joined --> show -.- subroot - - subgraph subroot["show() scope"] - direction LR - Button - end -end -``` - -`show()` will implicitly create an effect depending on `joined`, which can be -seen as `show` on the graph. This effect manages, and can create or destroy -a separate reactive scope seen as `show() scope` on the graph. The dotted line -indicates that it isn't actually connected, only indirectly managed through -code. +Control flow functions run their components in a new reactive scope, which can +be destroyed independently of the reactive scope that called the control flow +function. This means parts of your app can be independently created and +destroyed. ## switch() -Similar to `show()`, `switch()`, also condtionally displays one instance at a -time. It is more flexible since it can show one of many components, based on a -table used to map a source value to a component. +`switch()` condtionally displays one instance at a time. It uses a table to map +a source value to a component. ```lua local source = vide.source local switch = vide.switch +local function Button(props: { + Text: string, + Activated: () -> () +}) + local hovered = source(false) + + return create "TextButton" { + Text = props.Text, + Activated = props.Activated, + + TextColor3 = function() + return hovered() and Color3.new(1, 1, 1) or Color3.new(.7, .7, .7) + end, + + MouseEnter = function() hovered(true) end, + MouseLeave = function() hovered(false) end + } +end + local function JoinMenu() local joined = source(false) local function JoinButton() return Button { + Text = "Join", Activated = function() joined(true) end } end local function LeaveButton() return Button { + Text = "Leave" Activated = function() joined(false) end } end @@ -131,22 +65,6 @@ local function JoinMenu() end ``` -This example is equivalent to the previous one. - -The switch can map any value to any component. - -```lua -type ActiveMenu = "none" | "inventory" | "shop" | "settings" - -local menu = source "inventory" - -switch(menu) { - inventory = InventoryMenu, - shop = ShopMenu, - settings = SettingsMenu -} -``` - The reactive graph for the above example: ```mermaid @@ -164,23 +82,30 @@ The reactive graph for the above example: graph -subgraph root["mount() scope"] +subgraph root["root scope"] direction LR - menu --> switch -.- subroot + joined --> switch -.- subroot - subgraph subroot["switch() scope"] + subgraph subroot["switch scope"] direction LR - Menu + effect["TextColor3 effect"] end end ``` +A `switch()` call creates a new effect and a new scope as seen in the above +graph. Whenever `menu` updates, it causes the `switch` effect to run, which +will destroy and recreate the switch scope with the new component. + +This will also destroy the internal effect that the button uses to highlight +itself when it is hovered, each time the switch is rerun. + ## indexes() Often, you will have a table of values with each value displayed in a similar manner. Rather than manually looping over each value to generate a corresponding -UI element, `indexes()` allows you to create elements for each table index, to -display the value at that index. +UI element, `indexes()` allows you to create elements each corresponding to a +table index, to display the value at that index. ```lua local todoList = source { @@ -222,7 +147,8 @@ value), will have its corresponding reactive scope destroyed to clean up that element. `indexes()` is said to *map* each table index to a new UI element that can -update to display the current value at that index. +update to display the current value at that index. Each table index is given a +single corresponding UI element. The reactive graph for the above example: @@ -241,16 +167,16 @@ The reactive graph for the above example: graph -subgraph root ["mount() scope"] +subgraph root ["root scope"] direction LR todoList --> indexes -.- subroot1 & subroot2 - subgraph subroot1 ["indexes() scope 1"] + subgraph subroot1 ["indexes scope 1"] direction LR value1[todo] --> prop1["prop binding"] end - subgraph subroot2 ["indexes() scope 2"] + subgraph subroot2 ["indexes scope 2"] direction LR value2[todo] --> prop2[prop binding] end diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md index 2c2c313..62ee35d 100644 --- a/docs/tut/crash-course/15-concepts.md +++ b/docs/tut/crash-course/15-concepts.md @@ -6,73 +6,64 @@ A summary of all the concepts covered during the crash course. A source of data. -Stores a single value that can be updated by the user. +Stores a single value that can be updated. + +Created with `source()`. + +# Derived Source + +A new source composed of other sources. + +Created with a plain function or with `derive()`. ## Effect -Anything that happens in reponse to a source update. +Anything that happens in response to a source update. -Vide has built-in functions to create effects such as - -- `effect()` - runs arbitrary user code on source update -- `derive()` - updates a derived source on source update +Created with `effect()`. ## Reactive Scope -A scope created by certain Vide functions where source updates can be tracked, -and cleanups queued. - -When a source used inside a reactive scope is updated, the reactive scope will -rerun. - -Reactive scopes are created by functions such as +A scope created by certain functions such as: - `root()` - `effect()` - `derive()` -## Owner +Reactive scopes can: -A reactive scope created within an outer reactive scope, is *owned* by the outer -reactive scope. +- track sources that are read from within. +- rerun when a tracked source updates. +- track new reactive scopes created from within. -When a reactive scope is re-ran or destroyed, all reactive scopes owned by it -are also destroyed. +## Scope Owners -Vide does not let you create reactive scopes without owners. +A reactive scope created within another reactive scope is *owned* by the other +reactive scope, with the exception of the reactive scope created by `root()`. -## Root Reactive Scope +When a reactive scope is rerun or destroyed, all reactive scopes owned by it are +automatically destroyed. -A top-level reactive scope. These scopes are an exception to the owner rule. - -Created by `root()`, which `mount()` uses internally. - -A root reactive scope can be created on its own. It allows other reactive scopes -to be created with an owner. - -Root reactive scopes must be destroyed manually by the user, a function to do -this is given by `root()`. - -A root reactive scope can be created within another reactive scope and it will -not automatically be owned by that scope. +`root()`, which `mount()` uses internally, creates a reactive scope with no +owner, since it must be destroyed manually using a destructor +returned. ## Cleanup -Cleans up the result from an effect. +Arbitrary code to run whenever a reactive scope is rerun or destroyed. -Unneeded in most cases, a cleanup is arbitrary code that can be ran before -a reactive scope is rerun or destroyed, so that the result from the previous -run can be cleaned up. A cleanup can be queued by using `cleanup()` within -a reactive scope. +Queue a function to run using `cleanup()`. ## Tracking -Reactive scopes are tracking by default, meaning sources read from within scope -will be tracked. +Sources read from within a reactive scope will be tracked. This can be disabled +using `untrack()`, which will make reactive scopes temporarily ignore sources +read. -A reactive scope can be made temporarily non-tracking within `untrack()`, so -that any source used will be ignored. The only function that creates a -nontracking reactive scope by default is `root()`. +The reactive scope created by `root()` is non-tracking by default. + +As a guard against misusage, a reactive scope cannot be created within a +reactive scope, unless it is made non-tracking using `untrack()`. ## Reactive Graph diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index c1cafc0..96f9dd2 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -6,7 +6,7 @@ Instances are created using `create()`. properties to assign when creating a new instance for that class. Luau allows us to omit parentheses `()` when calling functions with string or -table literals which Vide takes advantage of for brevity. +table literals which is recommended to use for brevity. ```lua local create = vide.create @@ -44,6 +44,5 @@ to a string key. When creating an instance with no properties, it is important to not forget to actually call the constructor: `create "Frame" {}` and not `create "Frame"`. To be clear, `create "Frame"` returns a *function* which is a constructor for -that class, not an instance of that class. This would result in you attempting -to parent a function instead of an instance which is not correct. +that class, not an instance of that class. ::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index f6b3ad4..aa81916 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -35,7 +35,6 @@ return Button ``` ```lua [App.luau] -local mount = vide.mount local create = vide.create local Button = require(Button) @@ -60,7 +59,7 @@ local function App() } end -mount(App, game.StarterGui) +App().Parent = game.StarterGui ``` ::: @@ -76,8 +75,3 @@ To create a new button all you must do is call the `Button` function, passing in values. This saves having to create and set every property each time. Also, when updating the button component in future, any changes to the button file will be seen anywhere the button is used in your app. - -The `mount()` function is used to set up Vide's reactivity system when creating -your UI. It only needs to be called once at the top-level with the function that -puts together your entire app. It also parents the returned instance to another -a target instance for you. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 9f0dd6b..275a2e6 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -21,11 +21,11 @@ count(1) -- "count: 1" printed ``` -The callback given to `effect()` is initially ran immediately in a -*reactive scope*. Any source read from inside a reactive scope will be tracked, -so that if any of those sources update, the effect will be reran too. +The callback given to `effect()` is ran immediately in a *reactive scope*. Any +source read from inside a reactive scope will be tracked, so when any of those +sources update, the effect will be reran too. -Effects also work with derived sources, it doesn't matter how deeply nested +Reactive scopes also track derived sources, it doesn't matter how deeply nested inside a function a source is. ```lua diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md index abeec2f..4cb47da 100644 --- a/docs/tut/crash-course/6-root.md +++ b/docs/tut/crash-course/6-root.md @@ -1,12 +1,16 @@ # Root Reactive Scopes -Any reactive scopes created, such as by `effect()`, must be done so within a -"root" reactive scope. This is the main purpose of `mount()`, which you use -once at the top level to create your UI. +Reactive scopes cannot be created on their own - they must be created within +another reactive scope so that it can be tracked and later destroyed when it is +no longer needed. -This is so that if you want to destroy your UI, it can stop any reactive scopes -created within it, since reactive scopes track any reactive scopes created -within them. +This is the purpose of `mount()`, which creates an initial "root", or +"top-level" reactive scope, which all other reactive scopes, such as +ones created by `effect()`, can stem from. + +When this root reactive scope is destroyed, it will ensure all other reactive +scopes created within it are also destroyed, ensuring everything is cleaned up +properly. ```lua local source = vide.source @@ -20,13 +24,15 @@ local function App() end) end -vide.mount(App) -- works! App() -- will error since effect() was not called within a reactive scope + +vide.mount(App) -- works! + ``` -Mounting returns a function that when called will destroy any reactive scopes -created during the `mount()` call. +Mounting returns a function that when called will destroy its reactive scope, +along with any other reactive scopes created inside it. ```lua local unmount = mount(App) @@ -68,7 +74,7 @@ memory. The effect being destroyed will remove this reference, allowing the instance to be garbage collected. You don't need to worry about ensuring all your effects are created within a -root scope, since you should be creating all your UI and corresponding effects -within a top-level `mount()` call that puts all your UI together. So it is safe -to assume that any effect you create will be created under this top level scope. -Vide will prevent you from accidently doing otherwise anyways. +root reactive scope, since you should be creating all your UI and corresponding +effects within a top-level `mount()` call that puts all your UI together. So it +is safe to assume that any effect you create will be created under this top +level scope. Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 12f16a2..85139db 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,6 +1,6 @@ # Stateful Components -A stateful component is a component that can update in reponse to data. +A stateful component is a component that stores some data internally. Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. @@ -27,13 +27,18 @@ local function Counter() return instance end + +mount(Counter, game.StarterGui) ``` Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the -count source is created inside the scope of the component. +count source is created inside the component. + +We use `mount()` to create the counter within a reactive scope, which also takes +a second argument to parent the counter to another instance. ## External State diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md index a981b8a..1fe18fc 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -1,8 +1,7 @@ # Property Binding -Explicitly creating effects to update properties can become verbose when there -are a lot of properties to update. Vide provides a way to *implicitly* create -an effect to update properties on source update. +Explicitly creating effects to update properties can be tedious. Vide provides a +way to *implicitly* create an effect to update properties. ```lua local create = vide.create @@ -12,12 +11,12 @@ local function Counter() local count = source(0) return create "TextButton" { - Text = function() - return "count: " .. count() - end, - Activated = function() count(count() + 1) + end, + + Text = function() + return "count: " .. count() end } end diff --git a/src/graph.luau b/src/graph.luau index ecda1ab..2908e3d 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -45,9 +45,9 @@ local function assert_owning_scope(): Node if not scope then local caller_name = debug.info(2, "n") - return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) + return throw(`cannot use {caller_name}() in a non-reactive scope`) elseif scope.effect then - throw("cannot create new reactive scope inside of a tracking scope") -- todo: allow this? + throw("cannot create new reactive scope in a tracking reactive scope") end return scope From 3d324cc30bed0b334a1a6d35d580e6c3717cad02 Mon Sep 17 00:00:00 2001 From: ReturnedTrue <58662983+ReturnedTrue@users.noreply.github.com> Date: Mon, 19 Feb 2024 21:06:06 +0000 Subject: [PATCH 56/56] Update strict-mode.md (#24) --- docs/api/strict-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 958672e..a7fca48 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -33,6 +33,6 @@ As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly when binding properties to sources. -It is recommend to develop UI with strict mode and to disable it when pushing to +It is recommended to develop UI with strict mode and to disable it when pushing to production. In Roblox, production code compiles at O2 by default, so you don't need to worry about disabling strict mode unless you have manually enabled it.