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