diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 876fbd3..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.luau linguist-language=Lua diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a6cd4dc..4a9db7f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -14,7 +14,7 @@ jobs: uses: robinraju/release-downloader@v1.6 with: repository: Roblox/luau - latest: true + tag: "0.620" fileName: luau-ubuntu.zip out-file-path: bin diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d16e5..89cf8e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- +## Unreleased + +### Fixed + +- Error stack traces being lost. + +-------------------------------------------------------------------------------- + ## [0.2.0] - 2023-11-22 ### Added diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 87dada1..7708fdf 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -43,16 +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: "Root Scopes", link: "/tut/crash-course/6-root" }, + { text: "Scopes", link: "/tut/crash-course/6-scope" }, { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, - { text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, + { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, - { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, - { text: "Actions", link: "/tut/crash-course/13-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" }, - { text: "Concepts Summary", link: "/tut/crash-course/15-concepts" } + { text: "Actions", link: "/tut/crash-course/12-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } ] }, { diff --git a/docs/api/animation.md b/docs/api/animation.md index 16e48d7..6481cc3 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -18,8 +18,8 @@ Returns a new source with a value always moving torwards the input source value. - **Details** - The output source value is updated every step based on the input source - value. + An effect is created to update the new source every frame based on the input + source value. The movement is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). diff --git a/docs/api/creation.md b/docs/api/creation.md index 8e67478..539967d 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -4,7 +4,7 @@ ## mount() -Runs a function in a new reactive scope and optionally applies its result to a +Runs a function in a new stable scope and optionally applies its result to a target instance. - **Type** @@ -18,10 +18,10 @@ target instance. 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 + The function is ran in a new stable scope, just like [root()](reactivity-core.md#root). - Returns a function that when called will destroy the reactive scope. + Returns a function that when called will destroy the stable scope. - **Example** @@ -170,7 +170,7 @@ A wrapper for `action()` to listen for property changes. 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. + The changed connection is disconnected when the scope the action is ran in + is destroyed. Runs with an action priority of 1. diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 133bc4a..8b860d0 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -3,12 +3,13 @@
:::warning -Yielding is not allowed in any reactive scope. Strict mode can check for this. +Yielding is not allowed in any stable or reactive scope. Strict mode will check +for this. ::: ## root() -Creates and runs a function in a new reactive scope. +Creates and runs a function in a new stable scope. - **Type** @@ -20,8 +21,8 @@ Creates and runs a function in a new reactive scope. 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. + Creates a new stable scope, where creation of effects can be tracked and + properly disposed of. A function to destroy the root is passed into the callback, which will run any cleanups and allow derived sources created to garbage collect. @@ -41,11 +42,6 @@ Creates a new source with the given value. Calling the returned source with no argument will return its stored value, calling with an argument will set a new value. - Reading from the source from within 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 @@ -68,10 +64,10 @@ Runs a side-effect in a new reactive scope on source update. - **Details** - Any time a source referenced in the callback is changed, the callback will + Any time a source referenced in the callback is updated, the callback will be reran. - The callback is ran to initially ran on first call to find dependent sources. + The callback is ran once immediately. - **Example** @@ -107,7 +103,7 @@ Derives a new source in a new reactive scope from existing sources. Anytime its value is recalculated it is also cached, subsequent calls will retun this cached value until it recalculates again. - The callback is ran to initially ran on first call to find dependent sources. + The callback is ran once immediately. - **Example** diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md index 47dafa8..6870367 100644 --- a/docs/api/reactivity-flow.md +++ b/docs/api/reactivity-flow.md @@ -18,12 +18,12 @@ Shows one of two components depending on an input source. 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 + component will be reran under a new stable scope. If it changes from a + truthy to falsey value, the stable 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 + The fallback component is also ran under a new stable scope, and destroyed when the input source switches back to truthy. ## switch() @@ -41,10 +41,10 @@ Shows one of a set of components depending on an input source and a mapping tabl Returns a source holding an instance of the currently shown component. 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 + mapping table to get a component, which will be ran under a new stable + scope. If the input source changes, the stable 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 + stable scope. If no component is found for an input value, the switch will output `nil`. - **Example** @@ -82,9 +82,9 @@ Maps each index in a table source to an object. 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 + - For any new index, the `transform` function is ran under a new stable scope to produce a new instance. - - For any removed index, the reactive scope for that index is destroyed. + - For any removed index, the stable scope for that index is destroyed. - Unchanged indexes are untouched. The transform function is called only ever *once* for each index in the @@ -142,9 +142,9 @@ Maps each value in a table source to an object. 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 + - For any new value, the `transform` function is ran under a new stable scope to produce a new instance. - - For any removed value, the reactive scope for that value is destroyed. + - For any removed value, the stable scope for that value is destroyed. - Unchanged values are untouched. The transform function is only ever called *once* for each value in the @@ -203,7 +203,7 @@ Maps each value in a table source to an object. - Toast notifications. `indexes()` should be used in other cases, especially when your source table - has primitive value. It maps an index to a UI element. + has primitive values. It maps an index to a UI element. e.g. - List of character or weapon stats. @@ -213,6 +213,6 @@ Maps each value in a table source to an object. 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. + can be used to animate a change in position for the UI element. -------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index ee82067..7f08657 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 reran or destroyed. +Runs a callback anytime a scope is reran or destroyed. - **Type** @@ -31,8 +31,7 @@ Runs a callback anytime a reactive scope is reran or destroyed. ## untrack() -Runs a given function where any sources read will not be tracked by a reactive -scope. +Runs a given function in a new stable scope. - **Type** @@ -42,8 +41,8 @@ scope. - **Details** - Updates made to a source passed to `untrack()` will not cause updates to - anything depending on that source. + Can be used inside a reactive scope to read from sources you do not want + tracked by the reactive scope. - **Example** @@ -76,7 +75,7 @@ read can still be tracked inside a reactive scope. ## batch() Runs a given function where any source updates made within the function do not -trigger effects until after the function runs. +trigger effects until after the function finishes running. - **Type** diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index a7fca48..570108b 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -22,12 +22,12 @@ 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 derived sources and effects twice each time they update,it helps +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. +which strict mode will catch. As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md index 17c95d2..fb31c15 100644 --- a/docs/tut/advanced/nested-scoping.md +++ b/docs/tut/advanced/nested-scoping.md @@ -1,7 +1,6 @@ -# Nested Reactive Scopes +# Nested 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 +Nesting 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 @@ -21,7 +20,7 @@ local function Counter() } end -mount(function() +root(function() local toggled = source(true) show(toggled, Button) @@ -57,7 +56,7 @@ 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 +scope rerunning will destroy any scopes 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()`: @@ -82,7 +81,10 @@ mount(function() effect(function() if toggled() then - local destroy = mount(Button) + local destroy = root(function(destroy) + Counter() + return destroy + end) cleanup(destroy) end end) @@ -116,11 +118,14 @@ subgraph mount 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. +This is another way to achieve the same. Here we use `root()` within the effect +to manually create and destroy a new stable scope whenever the effect reruns. -Alternatively, instead of using `mount()`, a new reactive scope can be created -directly within the effect: +The reason for creating a stable scope is to prevent the effect from tracking +any sources that may be read inside the `Counter()` call. Otherwise, the effect +may be rerun needlessly and recreate the counter. + +Alternatively, instead of using `root()`: ```lua local mount = vide.mount @@ -174,7 +179,9 @@ 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 +the creation of reactive scopes inside reactive scopes. `untrack()` creates a +stable scope inside the reactive scope, and we can create another reactive scope +inside that stable scope. 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 @@ -184,5 +191,3 @@ 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/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 261ce83..983f51b 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -4,22 +4,12 @@ This is a tutorial that introduces the concepts and usage of Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). -This tutorial assumes familiarity with Luau and Roblox UI. - ## Why Vide? -Creating UI is 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. +Vide provides a reactive and declarative API to simplify managing UI. Some of the main focuses behind Vide's design choices: -- Minimal syntax. +- Minimal syntax - Complete typechecking -- Independence from instances. - -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. +- Independence from instances diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index bf632f5..a863093 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,44 +2,33 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which -is used to queue a cleanup callback for the next time a reactive scope is rerun -or destroyed. +is used to queue a callback for the next time a reactive scope is rerun or +destroyed, or when a stable scope is destroyed. ```lua -local mount = vide.mount +local root = vide.root local source = vide.source -local cleanup = vide.cleanup +local effect = vide.effect -local function Timer() - local count = source(0) +local count = source(0) - local con = game:GetService("RunService").Heartbeat:Connect(function(dt) - count(count() + dt) + +local destroy = root(function(destroy) + effect(function() + local x = count() + cleanup(function() print(x) end) end) - cleanup(function() - con:Disconnect() - end) + cleanup(function() print "root destroyed" end) - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), + return destroy +end) - Text = function() - return "seconds: " .. math.floor(count()) - end, - } -end - -local unmount = mount(Timer) - -unmount() -- all queued cleanups are ran, heartbeat connection disconnected +count(1) -- prints "0" +count(2) -- prints "1" +destroy() -- prints "2" and "root destroyed" ``` -In the above example, this allows us to disconnect the heartbeat connection -when the reactive scope responsible for creating the timer component is -destroyed, such as when it is unmounted. - ::: tip Roblox instances do not need to be explicitly destroyed for their memory to be freed, they only need to be parented to `nil`. So there is no diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index 91f95ac..b206915 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -5,119 +5,26 @@ resulting from source updates. Vide provides functions to help you do this, known as *control flow* functions. These functions return new sources, which hold the instances to be displayed. - -Control flow functions run their components in a new reactive scope, which can -be destroyed independently of the reactive scope that called the control flow -function. This means parts of your app can be independently created and -destroyed. - -## switch() - -`switch()` condtionally displays one instance at a time. It uses a table to map -a source value to a component. - -```lua -local source = vide.source -local switch = vide.switch - -local function Button(props: { - Text: string, - Activated: () -> () -}) - local hovered = source(false) - - return create "TextButton" { - Text = props.Text, - Activated = props.Activated, - - TextColor3 = function() - return hovered() and Color3.new(1, 1, 1) or Color3.new(.7, .7, .7) - end, - - MouseEnter = function() hovered(true) end, - MouseLeave = function() hovered(false) end - } -end - -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Text = "Join", - Activated = function() joined(true) end - } - end - - local function LeaveButton() - return Button { - Text = "Leave" - Activated = function() joined(false) end - } - end - - return create "Frame" { - switch(joined) { - [true] = LeaveButton, - [false] = JoinButton - } - } -end -``` - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root["root scope"] - direction LR - joined --> switch -.- subroot - - subgraph subroot["switch scope"] - direction LR - effect["TextColor3 effect"] - end -end -``` - -A `switch()` call creates a new effect and a new scope as seen in the above -graph. Whenever `menu` updates, it causes the `switch` effect to run, which -will destroy and recreate the switch scope with the new component. - -This will also destroy the internal effect that the button uses to highlight -itself when it is hovered, each time the switch is rerun. +The new sources can be used in `create()` to update the children of a container +instance. ## indexes() -Often, you will have a table of values with each value displayed in a similar -manner. Rather than manually looping over each value to generate a corresponding -UI element, `indexes()` allows you to create elements each corresponding to a -table index, to display the value at that index. +`indexes()` *maps* each table index to a new UI element that can +update to display the current value at that index. Each table index is given a +single corresponding UI element. ```lua -local todoList = source { +local list = source { "finish the crash course", - "star vide's GitHub" + "star Vide's GitHub" } local function TodoList(props: { list: () -> Array }) return create "Frame" { create "UIListLayout" {}, - indexes(todoList, function(todo, i) + indexes(list, function(todo, i) return create "TextLabel" { Text = function() return i .. ": " .. todo() @@ -129,13 +36,13 @@ local function TodoList(props: { list: () -> Array }) } end -TodoList { list = todoList } +TodoList { list = list } ``` -For each index in the given source table, the given function will be called -with: +For each index in the given source table, the given function to `indexes()` will +be run in a new stable scope with: -1. a source containing the value of the index +1. a source containing the value at the index 2. the index itself When the value at an index is changed, the function is not reran. Instead, the @@ -143,12 +50,9 @@ given source for that index is updated. Any time the input source table is updated, the given function will be ran for any newly added indexes, while any removed indexes (indexes now with a `nil` -value), will have its corresponding reactive scope destroyed to clean up that -element. +value), will have its corresponding stable scope destroyed. + -`indexes()` is said to *map* each table index to a new UI element that can -update to display the current value at that index. Each table index is given a -single corresponding UI element. The reactive graph for the above example: @@ -183,8 +87,8 @@ subgraph root ["root 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. +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 } @@ -192,11 +96,3 @@ 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/13-actions.md b/docs/tut/crash-course/12-actions.md similarity index 89% rename from docs/tut/crash-course/13-actions.md rename to docs/tut/crash-course/12-actions.md index 06b431d..176c12e 100644 --- a/docs/tut/crash-course/13-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -24,6 +24,7 @@ action used to listen for property changes: ```lua local action = vide.action +local effect = vide.effect local cleanup = vide.cleanup local function changed(prop: string, callback: (new) -> ()) @@ -44,9 +45,11 @@ local instance = create "TextBox" { changed("Text", output) } -instance.Text = "foo" +effect(function() + print(output()) +end) -print(output()) -- "foo" +instance.Text = "foo" -- "foo" will be printed from the effect ``` The source `output` will be updated with the new property value any time it is diff --git a/docs/tut/crash-course/12-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md deleted file mode 100644 index d8c44ce..0000000 --- a/docs/tut/crash-course/12-property-nesting.md +++ /dev/null @@ -1,120 +0,0 @@ -# Nested Properties - -Often when creating components from existing components, you can find yourself -repetitively passing through properties such as size or position. - -Example below: - -```lua -function Background(props: { - Color: Color3, - AnchorPoint: UDim2, - Position: UDim2, - Size: UDim2 -}) - return create "Frame" { - Color = props.Color - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size - } -end - -function Menu(props: { - Color = props.Color - AnchorPoint: UDim2, - Position: UDim2, - Size: UDim2 -}) - return Background { - Color = props.Color, - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size - } -end -``` - -One way this can be avoided is by using *property nesting*. In Vide, passing a -table value inside `props` has special semantics. Any key with a table value is -not assigned like a property, instead the table is iterated and processed just -like the outer table is. Any properties in the nested table will be assigned -to the instance just the same. - -Below is an example of how you can use this to pass groups of similar properties -together such as position and size, while also using typechecking. - -```lua -type Layout = { - Layout = { - Position: UDim2?, - Size: UDim2?, - AnchorPoint: Vector2? - } -} - -function Background(props: Layout & { Color: Color3 }) - return create "Frame" { - Color = props.Color, - props.Layout - } -end - -function Menu(props: Layout & { Color: Color3 }) - return Background { - Color = props.Color, - Layout = props.Layout - } -end -``` - -Here we created a nested group with the key `Layout` that can accept -layout-related properties. Any name could be chosen for the key. -This allows us to write much more concise syntax that is also typecheckable. - -In another example we use a key named `Children` to pass arrays of instances to -be parented. - -```lua -type Children = { - -- also can optionally pass a source that returns an array of children too - Children = Array | () -> Array -} - -local function List(props: Children & Layout) - return create "Frame" { - props.Children, - props.Layout, - create "UIListLayout" {} - } -end - -List { - Layout = { - Position = UDim2.new() - }, - - Children = { - create "TextLabel" { Text = "1" }, - create "TextLabel" { Text = "2" } - } -} -``` - -Deeper nested properties are guaranteed to be set after shallower nested -properties, this can be used to create overridable default properties. - -```lua -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), - - create "UIListLayout" {} - } -end -``` diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md similarity index 93% rename from docs/tut/crash-course/14-strict-mode.md rename to docs/tut/crash-course/13-strict-mode.md index ca8465a..6efa1c3 100644 --- a/docs/tut/crash-course/14-strict-mode.md +++ b/docs/tut/crash-course/13-strict-mode.md @@ -11,7 +11,7 @@ want this. Strict mode will run derived sources and effects twice each time they update. This is to help ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done correctly. +cleanups made in derived sources or effects are done properly. ```lua local source = vide.source diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/14-concepts.md similarity index 56% rename from docs/tut/crash-course/15-concepts.md rename to docs/tut/crash-course/14-concepts.md index 62ee35d..0b9dc0c 100644 --- a/docs/tut/crash-course/15-concepts.md +++ b/docs/tut/crash-course/14-concepts.md @@ -22,52 +22,52 @@ Anything that happens in response to a source update. Created with `effect()`. -## Reactive Scope +## Stable Scope -A scope created by certain functions such as: +One of the two types of Vide scopes. + +Created by: - `root()` +- `untrack()` +- `switch()` +- `indexes()` + +Stable scopes do not track sources and never rerun. + +New stable or reactive scopes can be created within a stable scope. + +## Reactive Scope + +Created by: + - `effect()` - `derive()` -Reactive scopes can: +Reactive scopes do track sources and will rerun when those sources update. -- track sources that are read from within. -- rerun when a tracked source updates. -- track new reactive scopes created from within. +New reactive scopes cannot be created within a reactive scope, but stable scopes +can. ## Scope Owners -A reactive scope created within another reactive scope is *owned* by the other -reactive scope, with the exception of the reactive scope created by `root()`. +A scope created within another scope is *owned* by the other scope, with the +exception of the scope created by `root()`. -When a reactive scope is rerun or destroyed, all reactive scopes owned by it are -automatically destroyed. +When a scope is rerun or destroyed, all scopes owned by it are automatically +destroyed. -`root()`, which `mount()` uses internally, creates a reactive scope with no -owner, since it must be destroyed manually using a destructor -returned. +`root()` creates a stable scope with no owner, instead it is destroyed manually. ## Cleanup -Arbitrary code to run whenever a reactive scope is rerun or destroyed. +Arbitrary code to run whenever a stable or reactive scope is rerun or destroyed. Queue a function to run using `cleanup()`. -## Tracking - -Sources read from within a reactive scope will be tracked. This can be disabled -using `untrack()`, which will make reactive scopes temporarily ignore sources -read. - -The reactive scope created by `root()` is non-tracking by default. - -As a guard against misusage, a reactive scope cannot be created within a -reactive scope, unless it is made non-tracking using `untrack()`. - ## Reactive Graph -The combination of reactive scopes can viewed graphically, called a +The combination of stable and 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. @@ -114,10 +114,10 @@ count --> text Notes: - Since `count` is a source, not an effect, it can exist - outside of a root reactive scope. + outside of scopes. - An update to `count` will cause `text` to rerun, which then causes `effect` to rerun. -- When the root reactive scope is destroyed, `text` and +- When the root 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 96f9dd2..5e76173 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -2,11 +2,8 @@ Instances are created using `create()`. -`create()` returns a constructor for a class which then takes a table of -properties to assign when creating a new instance for that class. - -Luau allows us to omit parentheses `()` when calling functions with string or -table literals which is recommended to use for brevity. +Parentheses `()` can be omitted when calling functions with string or +table literals for brevity. ```lua local create = vide.create @@ -39,10 +36,3 @@ return create "ScreenGui" { 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. - -::: warning -When creating an instance with no properties, it is important to not forget to -actually call the constructor: `create "Frame" {}` and not `create "Frame"`. -To be clear, `create "Frame"` returns a *function* which is a constructor for -that class, not an instance of that class. -::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index aa81916..8e5890f 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -34,12 +34,12 @@ end return Button ``` -```lua [App.luau] +```lua [Menu.luau] local create = vide.create local Button = require(Button) -local function App() +local function Menu() return create "ScreenGui" { Button { Position = UDim2.fromOffset(200, 200), @@ -58,20 +58,8 @@ local function App() } } end - -App().Parent = game.StarterGui ``` ::: -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. - -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. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index c356b6b..08da45c 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,7 +1,7 @@ # Sources -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. +Sources are special objects that store a single value and are the core of +Vide's reactivity. A source can be created using `source()`. @@ -20,8 +20,7 @@ by calling it with no arguments. count(count() + 1) -- increment count by 1 ``` -Sources can be *derived* by wrapping them in functions. A wrapped source -effectively becomes a new source. +Sources can be *derived* by wrapping them in functions. ```lua local count = source(0) @@ -35,7 +34,5 @@ count(1) print(text()) -- "count: 1" ``` -Sources on their own aren't very special, the above can be achieved with plain -variables. The real use for sources become apparent when used in combination -with *effects*. Similar to a signal and connection, a source and effect allows -you to do things like automatically updating UI when a source is updated. +While the above can be achieved with plain variables, the use for sources will +be obvious in the next part. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 275a2e6..5e6043c 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,8 +1,7 @@ # 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. +A source and effect is analogous to a signal and connection. Effects are created using `effect()`. @@ -21,11 +20,10 @@ count(1) -- "count: 1" printed ``` -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. +Any source read inside an effect is tracked and will rerun the effect when +that source is updated. -Reactive scopes also track derived sources, it doesn't matter how deeply nested +Derived sources are also tracked, 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 deleted file mode 100644 index 4cb47da..0000000 --- a/docs/tut/crash-course/6-root.md +++ /dev/null @@ -1,80 +0,0 @@ -# Root Reactive Scopes - -Reactive scopes cannot be created on their own - they must be created within -another reactive scope so that it can be tracked and later destroyed when it is -no longer needed. - -This is the purpose of `mount()`, which creates an initial "root", or -"top-level" reactive scope, which all other reactive scopes, such as -ones created by `effect()`, can stem from. - -When this root reactive scope is destroyed, it will ensure all other reactive -scopes created within it are also destroyed, ensuring everything is cleaned up -properly. - -```lua -local source = vide.source -local effect = vide.effect - -local function App() - local count = source(0) - - effect(function() - print(count()) - end) -end - - -App() -- will error since effect() was not called within a reactive scope - -vide.mount(App) -- works! - -``` - -Mounting returns a function that when called will destroy its reactive scope, -along with any other reactive scopes created inside it. - -```lua -local unmount = mount(App) - -unmount() -``` - -Vide's reactivity can be represented graphically, as a *reactive graph*. - -The reactive graph for the above example looks like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - -subgraph root - direction LR - count --> effect -end -``` - -When the root reactive scope created by `mount()` is destroyed, the `effect` -scope will also be destroyed since it was created within it. - -This is important because you may have an effect that updates the property of a -UI instance, meaning the effect is referencing and holding that instance in -memory. The effect being destroyed will remove this reference, allowing the -instance to be garbage collected. - -You don't need to worry about ensuring all your effects are created within a -root reactive scope, since you should be creating all your UI and corresponding -effects within a top-level `mount()` call that puts all your UI together. So it -is safe to assume that any effect you create will be created under this top -level scope. Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md new file mode 100644 index 0000000..fa6ea8f --- /dev/null +++ b/docs/tut/crash-course/6-scope.md @@ -0,0 +1,105 @@ +# Scopes + +Just like how a signal's connection may need to be disconnected, a source's +effect also may need to be disconnected. + +But the disconnecting of many signals and connections is tedious and verbose. +Vide instead operates on the concept of scopes which provides a much cleaner +API, given that you follow a few rules. + +Scopes come in two flavors; stable and reactive. + +- All scopes must be created within another scope with the exception of `root()` +- Stable scopes never rerun +- Reactive scopes can rerun +- A reactive scope cannot be created within another reactive scope + +`effect()` creates a reactive scope. +`root()` creates a stable scope. + +Whenever a scope is destroyed, any scope created within that scope is also +destroyed, and so on. This is why all scopes must be created within another +scope, except `root()` which is used to create the initial scope that you can +manually destroy. + +```lua +local root = vide.root +local source = vide.source +local effect = vide.effect + +local function setup() + local count = source(0) + + effect(function() + print(count()) + end) + + return count +end + +setup() -- will error since effect() tries to create a reactive scope outside of a stable scope + +local count = root(setup) -- ok since effect() was called within a stable scope +count(1) -- prints "1" +``` + +The scope created by `root()` can be destroyed by calling the function it passes +into the given function. + +```lua +local function setup(destroy) + local count = source(0) + + effect(function() + print(count()) + end) + + return count, destroy +end + +local count, destroy = root(setup) + +count(1) -- prints "1" + +destroy() + +count(2) -- effect is destroyed; no longer prints +``` + +Vide's reactivity can be represented graphically, as a *reactive graph*. + +The reactive graph for the above example looks like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +graph + +subgraph root + direction LR + count --> effect +end +``` + +When the stable `root()` is destroyed, the reactive `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 +stable scope, since you should be creating all your UI and effects within a +single top-level `root()` call that puts all your UI together, making it safe to +assume any effect created will be created under this stable scope. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 85139db..493f9bc 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,7 +1,5 @@ # Stateful Components -A stateful component is a component that stores some data internally. - Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. @@ -27,8 +25,6 @@ local function Counter() return instance end - -mount(Counter, game.StarterGui) ``` Above is an example of a counter component, that when clicked, will increment @@ -37,9 +33,6 @@ its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the component. -We use `mount()` to create the counter within a reactive scope, which also takes -a second argument to parent the counter to another instance. - ## External State External sources can also be passed into components for them to use. @@ -72,4 +65,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. +within a stable scope. diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-implicit-effect.md similarity index 60% rename from docs/tut/crash-course/8-property-binding.md rename to docs/tut/crash-course/8-implicit-effect.md index 1fe18fc..bbd92d4 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -1,7 +1,7 @@ -# Property Binding +# Implicit Effects -Explicitly creating effects to update properties can be tedious. Vide provides a -way to *implicitly* create an effect to update properties. +Explicitly creating effects to update properties is tedious. You can +*implicitly* create an effect to update properties instead. ```lua local create = vide.create @@ -25,23 +25,14 @@ end This example is equivalent to the example seen on the previous page. Instead of explicitly creating an effect, assigning a (non-event) property a -function will implicitly create an effect to update that property anytime a -source used within is updated. +function will implicitly create an effect to update that property. -Just like effects, the function is ran immediately in a reactive scope to set -the property initially and determine what sources are being used. - -This allows you as the programmer to not need to manually update UI as the state -of your program changes. You just define how data sources map to UI, and Vide's -reactive system will automatically update any properties depending on those -sources. - -## Children Binding +## Children 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. +instances. An effect is automatically created to unparent removed instances and +parent new instances on source update. ```lua local items = source { @@ -62,5 +53,5 @@ items { create "TextLabel" { Text = "C" } } --- this will automatically unparent the text label "A", and parent the labels "B" and "C". +-- this will automatically unparent the text label "A", and parent the labels "B" and "C" ``` diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index d3b7156..ffcdaa9 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -36,14 +36,13 @@ source(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will -run a callback in a new reactive scope only when a dependent source has updated. -Reading this derived source multiple times will just return a cached result from -when it last updated. +run a function in a reactive scope only when a source used inside updated. +Reading this derived source multiple times will just return a cached result. ```lua local source = vide.source -local derive = vide.derive local effect = vide.effect +local derive = vide.derive local count = source(0) @@ -58,7 +57,7 @@ effect(function() text() end) source(1) -- prints "ran" x1 ``` -`derive()` must also be called within a reactive scope, just like `effect()`. +`derive()` must also be called within a stable 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. @@ -87,6 +86,7 @@ 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. +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 +source. diff --git a/src/bind.luau b/src/bind.luau index 4a204f1..dd34ce3 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,42 +1,13 @@ if not game then script = require "test/relative-string" end -local trace = require(script.Parent.trace) -local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local assert_owning_scope = graph.assert_owning_scope +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner -function create_binding(updater: (T) -> T, binding: T) - if flags.strict then - -- track bind creation trace - local fn = updater - local bind_trace = debug.traceback(nil, trace()-1) - updater = function(...) - local ok, result = xpcall(fn, function(err: string) - return err - end, ...) - - if not ok then - local btype = - if (binding :: any).property then (binding :: any).property - elseif (binding :: any).parent then "Parent" - else "children" - error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0) - end - - return result - end - end - - local owner = assert_owning_scope() - - local node = create_node(binding, updater) - - set_owner(node, owner) - evaluate_node(node) +function create_implicit_effect(updater: (T) -> T, binding: T) + evaluate_node(create_node(assert_stable_scope(), updater, binding)) end type PropertyBinding = { @@ -45,7 +16,7 @@ type PropertyBinding = { source: () -> unknown } -local function update_property(p: PropertyBinding) +local function update_property_effect(p: PropertyBinding) (p.instance :: any)[p.property] = p.source() return p end @@ -55,7 +26,7 @@ type ParentBinding = { parent: () -> Instance } -local function update_parent(p: ParentBinding) +local function update_parent_effect(p: ParentBinding) p.instance.Parent = p.parent() return p end @@ -67,7 +38,7 @@ type ChildrenBinding = { children: () -> Instance | { Instance } } -local function update_children(p: ChildrenBinding) +local function update_children_effect(p: ChildrenBinding) local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update @@ -100,7 +71,7 @@ end return { property = function(instance, property, source) - return create_binding(update_property, { + return create_implicit_effect(update_property_effect, { instance = instance, property = property, source = source @@ -108,14 +79,14 @@ return { end, parent = function(instance, parent) - return create_binding(update_parent, { + return create_implicit_effect(update_parent_effect, { instance = instance, parent = parent }) end, children = function(instance, children) - return create_binding(update_children, { + return create_implicit_effect(update_children_effect, { instance = instance, cur_children_set = {}, new_children_set = {}, diff --git a/src/cleanup.luau b/src/cleanup.luau index 803be03..8449fcc 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -4,7 +4,7 @@ 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 push_cleanup = graph.push_cleanup local function helper(obj: any) return @@ -21,13 +21,13 @@ local function cleanup(value: unknown) local scope = get_scope() if not scope then - throw "cannot cleanup in a non-reactive scope" + throw "cannot cleanup outside a stable or reactive scope" end; assert(scope) if type(value) == "function" then - add_cleanup(scope, value :: () -> ()) + push_cleanup(scope, value :: () -> ()) else - add_cleanup(scope, helper(value)) + push_cleanup(scope, helper(value)) end end diff --git a/src/derive.luau b/src/derive.luau index 863fd98..fb824a4 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -2,21 +2,17 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) local create_node = graph.create_node -local set_owner = graph.set_owner -local track = graph.track -local assert_owning_scope = graph.assert_owning_scope +local push_child_to_scope = graph.push_child_to_scope +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node local function derive(source: () -> T): () -> T - local owner = assert_owning_scope() + local node = create_node(assert_stable_scope(), source, false :: any) - local node = create_node(false :: any, source) - - set_owner(node, owner) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end diff --git a/src/effect.luau b/src/effect.luau index bbe1669..3acab21 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -2,16 +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 assert_owning_scope = graph.assert_owning_scope +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner local function effect(callback: (T) -> T, initial_value: T) - local owner = assert_owning_scope() + local node = create_node(assert_stable_scope(), callback, initial_value) - local node = create_node(initial_value, callback) - - set_owner(node, owner) evaluate_node(node) end diff --git a/src/graph.luau b/src/graph.luau index 2908e3d..f35b9b6 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) -export type StartNode = { +export type SourceNode = { cache: T, [number]: Node } @@ -16,21 +16,21 @@ export type Node = { owned: { Node } | false, owner: Node | false, - parents: { StartNode }, + parents: { SourceNode }, [number]: Node -- children } --- reactive scope stack -local scopes = { n = 0 } :: { [number]: Node, n: number } +local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack 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) + local thread = coroutine.create(xpcall) + local function efn(err: string) return debug.traceback(err, 3) end + local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) assert(resume_ok) if coroutine.status(thread) ~= "dead" then - return false, "attempt to yield in reactive scope" + return false, debug.traceback(thread, "attempt to yield in reactive scope") end return run_ok, result @@ -40,46 +40,37 @@ local function get_scope(): Node? return scopes[scopes.n] end -local function assert_owning_scope(): Node +local function assert_stable_scope(): Node local scope = get_scope() if not scope then local caller_name = debug.info(2, "n") - return throw(`cannot use {caller_name}() in a non-reactive scope`) + return throw(`cannot use {caller_name}() outside a stable or reactive scope`) elseif scope.effect then - throw("cannot create new reactive scope in a tracking reactive scope") + throw("cannot create a new reactive scope inside another reactive scope") end return scope end -local function add_child(parent: StartNode, child: Node) +local function push_child(parent: SourceNode, child: Node) table.insert(parent, child) table.insert(child.parents, parent) end -local function set_owner(node: Node, 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) +local function push_scope(node: Node) local n = scopes.n + 1 scopes.n = n scopes[n] = node end -local function close_scope() +local function pop_scope() local n = scopes.n scopes.n = n - 1 scopes[n] = nil end -local function add_cleanup(node: Node, cleanup: () -> ()) +local function push_cleanup(node: Node, cleanup: () -> ()) if node.cleanups then table.insert(node.cleanups, cleanup) else @@ -87,34 +78,35 @@ local function add_cleanup(node: Node, cleanup: () -> ()) end end -local function run_cleanups(node: Node) +local function flush_cleanups(node: Node) if node.cleanups then for _, fn in next, node.cleanups do local ok, err: string? = pcall(fn) if not ok then throw(`cleanup error: {err}`) end end + table.clear(node.cleanups) end end local function find_and_swap_pop(t: { T }, v: T) - local idx = table.find(t, v) :: number + local i = table.find(t, v) :: number local n = #t - t[idx] = t[n] + t[i] = t[n] t[n] = nil end local function unparent(node: Node) local parents = node.parents - for i, parent in next, parents do + for i, parent in parents do find_and_swap_pop(parent, node) parents[i] = nil end end local function destroy(node: Node) - run_cleanups(node) + flush_cleanups(node) unparent(node) if node.owner then @@ -138,44 +130,51 @@ end local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) - local cur_value = node.cache - if flags.strict then - run_cleanups(node) + local initial_value = node.cache + + for i = 1, 2 do + local cur_value = node.cache + + flush_cleanups(node) + destroy_owned(node) + + push_scope(node) + local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) + pop_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`effect stacktrace:\n{new_value :: string}`) + end + + node.cache = new_value :: T + end + + return initial_value ~= node.cache + else + local cur_value = node.cache + + flush_cleanups(node) destroy_owned(node) - open_scope(node) + push_scope(node) + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) + pop_scope() - local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) - - close_scope() - - if not ok then throw(new_value :: string) end - - node.cache = new_value :: T + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`effect stacktrace:\n{new_value}\n`) + end + + node.cache = new_value + return cur_value ~= new_value end - - run_cleanups(node) - destroy_owned(node) - - open_scope(node) - - local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) - - close_scope() - - if not ok then - table.clear(update_queue) - update_queue.n = 0 - throw(`side-effect error from source update\n{new_value}`) - end - - node.cache = new_value - - return cur_value ~= new_value end -local function queue_children(node: StartNode) +local function queue_children_for_update(node: SourceNode) local i = update_queue.n while node[1] do i += 1 @@ -198,7 +197,7 @@ local function flush_update_queue() --assert(node.effect) if node.owner and evaluate_node(node) then - queue_children(node) + queue_children_for_update(node) end update_queue[i] = false :: any @@ -210,9 +209,9 @@ local function flush_update_queue() _flushing = false end -local function update(root: StartNode) +local function update_descendants(root: SourceNode) local n0 = update_queue.n - queue_children(root) + queue_children_for_update(root) if flags.batch then return end @@ -223,7 +222,7 @@ local function update(root: StartNode) -- check if node is still owned in case destroyed after queued if node.owner and evaluate_node(node) then - queue_children(node) + queue_children_for_update(node) end update_queue[i] = false :: any -- false instead of nil to avoid sparse @@ -233,27 +232,37 @@ local function update(root: StartNode) update_queue.n = n0 end -local function track(node: StartNode) +local function push_child_to_scope(node: SourceNode) local scope = get_scope() if scope and scope.effect then -- do not track nodes with no effect - add_child(node, scope) + push_child(node, scope) end end -local function create_node(value: T, effect: false | (T) -> T): Node - return { +local function create_node(owner: false | Node, effect: false | (T) -> T, value: T): Node + local node: Node = { cache = value, effect = effect, cleanups = false, - owner = false, + owner = owner, owned = false, parents = {}, } + + if owner then + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end + end + + return node end -local function create_start_node(value: T): StartNode +local function create_source_node(value: T): SourceNode return { cache = value } end @@ -262,20 +271,19 @@ local function get_children(node: Node): { Node } end return table.freeze { - open_scope = open_scope, - close_scope = close_scope, + push_scope = push_scope, + pop_scope = pop_scope, evaluate_node = evaluate_node, get_scope = get_scope, - assert_owning_scope = assert_owning_scope, - add_cleanup = add_cleanup, - set_owner = set_owner, + assert_stable_scope = assert_stable_scope, + push_cleanup = push_cleanup, destroy = destroy, - run_cleanups = run_cleanups, - track = track, - update = update, - add_child = add_child, + flush_cleanups = flush_cleanups, + push_child_to_scope = push_child_to_scope, + update_descendants = update_descendants, + push_child = push_child, create_node = create_node, - create_start_node = create_start_node, + create_source_node = create_source_node, get_children = get_children, flush_update_queue = flush_update_queue, scopes = scopes diff --git a/src/maps.luau b/src/maps.luau index ef36209..23c1eb3 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -4,15 +4,14 @@ local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node -type StartNode = graph.StartNode +type SourceNode = graph.SourceNode local create_node = graph.create_node -local create_start_node = graph.create_start_node -local set_owner = graph.set_owner -local track = graph.track -local update = graph.update -local assert_owning_scope = graph.assert_owning_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local create_source_node = graph.create_source_node +local push_child_to_scope = graph.push_child_to_scope +local update_descendants = graph.update_descendants +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope local evaluate_node = graph.evaluate_node local destroy = graph.destroy @@ -22,20 +21,18 @@ local function check_primitives(t: {}) if not flags.strict then return end for _, v in next, t do - if type(v) == "table" or type(v) == "userdata" then continue end + if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end throw("table source map cannot return primitives") end end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = assert_owning_scope() - - local subowner = create_node(false, false) - set_owner(subowner, owner) + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) local input_cache = {} :: Map local output_cache = {} :: Map - local input_nodes = {} :: Map> + local input_nodes = {} :: Map> local remove_queue = {} :: { K } local scopes = {} :: Map> @@ -59,7 +56,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, table.clear(remove_queue) - open_scope(subowner) + push_scope(subowner) -- process new or changed values for i, v in next, data do @@ -67,23 +64,22 @@ 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(subowner, false, false) scopes[i] = scope :: Node - local node = create_start_node(v) + local node = create_source_node(v) - set_owner(scope, subowner) - open_scope(scope) + push_scope(scope) local ok, result = pcall(transform, function() - track(node) + push_child_to_scope(node) return node.cache end, i) - close_scope() + pop_scope() if not ok then - close_scope() -- subowner scope + pop_scope() -- subowner scope error(result, 0) end @@ -91,14 +87,14 @@ local function indexes(input: () -> Map, transform: (() -> VI, output_cache[i] = result else -- update source input_nodes[i].cache = v - update(input_nodes[i]) + update_descendants(input_nodes[i]) end input_cache[i] = v end end - close_scope() + pop_scope() local output_array = table.create(#scopes) for _, v in next, output_cache do @@ -109,29 +105,26 @@ local function indexes(input: () -> Map, transform: (() -> VI, return output_array end - local node = create_node(false :: any, function() + local node = create_node(owner, function() return update_children(input()) - end) - set_owner(node, owner) + end, false :: any) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = assert_owning_scope() - - local subowner = create_node(false, false) - set_owner(subowner, owner) + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) local cur_input_cache_up = {} :: Map local new_input_cache_up = {} :: Map local output_cache = {} :: Map - local input_nodes = {} :: Map> + local input_nodes = {} :: Map> local scopes = {} :: Map> local function update_children(data: Map) @@ -147,7 +140,7 @@ local function values(input: () -> Map, transform: (VI, () -> end end - open_scope(subowner) + push_scope(subowner) -- process data for i, v in next, data do @@ -156,23 +149,22 @@ 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(subowner, false, false) scopes[v] = scope :: Node - local node = create_start_node(i) + local node = create_source_node(i) - set_owner(scope, subowner) - open_scope(scope) + push_scope(scope) local ok, result = pcall(transform, v, function() - track(node) + push_child_to_scope(node) return node.cache end) - close_scope() + pop_scope() if not ok then - close_scope() -- subowner scope + pop_scope() -- subowner scope error(result, 0) end @@ -181,14 +173,14 @@ local function values(input: () -> Map, transform: (VI, () -> else -- update source if cv ~= i then input_nodes[v].cache = i - update(input_nodes[v]) + update_descendants(input_nodes[v]) end cur_input_cache[v] = nil end end - close_scope() + pop_scope() -- remove old values for v in next, cur_input_cache do @@ -212,15 +204,14 @@ local function values(input: () -> Map, transform: (VI, () -> return output_array end - local node = create_node(false :: any, function() + local node = create_node(owner, function() return update_children(input()) - end) - set_owner(node, owner) + end, false :: any) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end diff --git a/src/root.luau b/src/root.luau index 50a1c1c..f8fc983 100644 --- a/src/root.luau +++ b/src/root.luau @@ -4,14 +4,14 @@ local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope local destroy = graph.destroy local refs = {} local function root(fn: (destroy: () -> ()) -> T...): T... - local node = create_node(false, false) + local node = create_node(false, false, false) refs[node] = true -- prevent gc of root node @@ -21,15 +21,16 @@ local function root(fn: (destroy: () -> ()) -> T...): T... destroy(node) end - open_scope(node) + push_scope(node) - local result = { pcall(fn, destroy) } + local function efn(err: string) return debug.traceback(err, 3) end + local result = { xpcall(fn, efn, destroy) } - close_scope() + pop_scope() if not result[1] then refs[node] = nil - throw(`mount error\n{result[2]}`) + throw(`error while running root():\n\n{result[2]}`) end return unpack(result :: any, 2) diff --git a/src/source.luau b/src/source.luau index dd633bb..e326815 100644 --- a/src/source.luau +++ b/src/source.luau @@ -2,18 +2,18 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) type Node = graph.Node -local create_start_node = graph.create_start_node -local track = graph.track -local update = graph.update +local create_source_node = graph.create_source_node +local push_child_to_scope = graph.push_child_to_scope +local update_descendants = graph.update_descendants export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source - local node = create_start_node(initial_value) + local node = create_source_node(initial_value) return function(...): T if select("#", ...) == 0 then -- no args were given - track(node) + push_child_to_scope(node) return node.cache end @@ -23,7 +23,7 @@ local function source(initial_value: T): Source end node.cache = v - update(node) + update_descendants(node) return v end end diff --git a/src/spring.luau b/src/spring.luau index 601eb37..bd1a990 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -24,14 +24,13 @@ Unsupported datatypes: local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node -type StartNode = graph.StartNode +type SourceNode = graph.SourceNode local create_node = graph.create_node -local create_start_node = graph.create_start_node -local assert_owning_scope = graph.assert_owning_scope +local create_source_node = graph.create_source_node +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -local update = graph.update -local set_owner = graph.set_owner -local track = graph.track +local update_descendants = graph.update_descendants +local push_child_to_scope = graph.push_child_to_scope local UPDATE_RATE = 120 local TOLERANCE = 0.0001 @@ -111,7 +110,7 @@ local vec6_to_type = { end :: Vec6ToType, Color3 = function(v) - return Color3.new(v.X, v.Y, v.Z) + return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1)) end :: Vec6ToType, UDim = function(v) @@ -146,11 +145,11 @@ setmetatable(vec6_to_type, invalid_type) -- maps spring data to its corresponding output node -- lifetime of spring data is tied to output node -local springs: { [SpringData]: StartNode } = {} +local springs: { [SpringData]: SourceNode } = {} setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local owner = assert_owning_scope() + local owner = assert_stable_scope() -- https://en.wikipedia.org/wiki/Damping @@ -182,7 +181,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number source_value = false :: any, } - local output = create_start_node(false :: any) + local output = create_source_node(false :: any) local function updater_effect() local value = source() @@ -192,9 +191,8 @@ local function spring(source: () -> T, period: number?, damping_ratio: number return value end - local updater = create_node(false :: any, updater_effect) + local updater = create_node(owner, updater_effect, false :: any) - set_owner(updater, owner) evaluate_node(updater) -- set initial position to goal @@ -204,7 +202,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number output.cache = data.source_value return function() - track(output) + push_child_to_scope(output) return output.cache end end @@ -269,7 +267,7 @@ local function update_spring_sources() output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) end - update(output) + update_descendants(output) end for _, data in next, remove_queue do diff --git a/src/switch.luau b/src/switch.luau index 12fd376..99edd3c 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -3,20 +3,19 @@ if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node -type StartNode = graph.StartNode +type SourceNode = graph.SourceNode local create_node = graph.create_node local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner -local track = graph.track +local push_child_to_scope = graph.push_child_to_scope local destroy = graph.destroy -local assert_owning_scope = graph.assert_owning_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope type Map = { [K]: V } local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = assert_owning_scope() + local owner = assert_stable_scope() return function(map) local last_scope: Node? @@ -38,28 +37,26 @@ 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(owner, false, false) last_scope = new_scope :: Node - set_owner(new_scope, owner) - open_scope(new_scope) + push_scope(new_scope) local ok, result = pcall(component) - close_scope() + pop_scope() if not ok then error(result, 0) end return result end - local node = create_node(nil :: U?, update) + local node = create_node(owner, update, nil) - set_owner(node, owner) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end diff --git a/src/throw.luau b/src/throw.luau index 70b7973..954f3e2 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -1,9 +1,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) +local function VIDE_ASSERT(msg): any + error(msg, 0) end -return throw +return VIDE_ASSERT diff --git a/src/trace.luau b/src/trace.luau deleted file mode 100644 index 04672ff..0000000 --- a/src/trace.luau +++ /dev/null @@ -1,29 +0,0 @@ --- returns path to file as an array with each directory --- accounts for Roblox and Luau contexts -local function get_path(s) - if string.sub(s, #s - 4, #s) == ".luau" then - s = string.sub(s, 1, #s - 5) - end - - return string.split(s, string.match(s, "%w+/") and "/" or ".") -end - --- get directory of vide root -local root do - local path = get_path(debug.info(1, "s")) - root = path[#path - 1] -end - --- finds the first stack depth outside of any vide library function -return function(): number - local stack = 1 - - local path = get_path(debug.info(stack, "s")) - - while path[#path] == root or path[#path - 1] == root do - stack += 1 - path = get_path(debug.info(stack, "s")) - end - - return stack -end diff --git a/test/benchmark.luau b/test/benchmark.luau index bee8c68..c35b651 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -26,7 +26,6 @@ end local N = 2^18 -- 262144 - TITLE "sources" BENCH("create source", function() @@ -449,27 +448,6 @@ end) N *= 1024 -TITLE "cleanup" - -ROOT_BENCH("register new cleanup", function() - local cleanup = cleanup - - local cleaner = function() end - - local callers = {} - - for i = 1, N do - callers[i] = function(fn, v) - fn(v) - return i -- return unique upvalue to ensure unique closure - end - end - - for i = 1, START(N) do - callers[i](cleanup, cleaner) - end -end) - TITLE "aggregate" do diff --git a/test/tests.luau b/test/tests.luau index c38c6a7..c0f69d1 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -35,28 +35,27 @@ vide.strict = false TEST("graph", function() local create_node = graph.create_node - local track = graph.track - local update = graph.update - local add_child = graph.add_child + local push_child_to_scope = graph.push_child_to_scope + local update_descendants = graph.update_descendants + local push_child = graph.push_child 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 push_scope = graph.push_scope + local pop_scope = graph.pop_scope local get_children = graph.get_children - local add_cleanup = graph.add_cleanup + local push_cleanup = graph.push_cleanup local destroy = graph.destroy - local function node(v: T?) - return create_node(v or false, function(x) return not x end) + local function node(owner: Node?, v: T?) + return create_node(owner or false, function(x) return not x end, v or false :: any) end - local function scope() - return create_node(false, false) + local function scope(owner: Node?) + return create_node(owner or false, false, false) end local function cleanup(fn: () -> ()) local node = assert(get_scope()) - add_cleanup(node, fn) + push_cleanup(node, fn) end do CASE "link nodes" @@ -64,12 +63,12 @@ TEST("graph", function() local b = node() local c = node() - open_scope(c) + push_scope(c) - track(a) - track(b) + push_child_to_scope(a) + push_child_to_scope(b) - close_scope() + pop_scope() CHECK(get_children(a)[1] == c) CHECK(get_children(b)[1] == c) @@ -78,33 +77,30 @@ TEST("graph", function() 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 b = node(root) + local c = node(root) local count = 0 local function effect(x) - track(a) - track(b) + push_child_to_scope(a) + push_child_to_scope(b) count += 1 return not x end c.effect = effect - open_scope(c) + push_scope(c) effect(c.cache) - close_scope() + pop_scope() CHECK(count == 1) - update(a) + update_descendants(a) CHECK(count == 2) - update(b) + update_descendants(b) CHECK(count == 3) end @@ -112,22 +108,18 @@ TEST("graph", function() -- 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 a, b, c, d = node(), node(root), node(root), node(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 function d.effect(x) d_cnt += 1; return not x end - open_scope(b); track(a); close_scope() - open_scope(c); track(a); close_scope() - open_scope(d); track(b); track(c); close_scope() + push_scope(b); push_child_to_scope(a); pop_scope() + push_scope(c); push_child_to_scope(a); pop_scope() + push_scope(d); push_child_to_scope(b); push_child_to_scope(c); pop_scope() - update(a) + update_descendants(a) CHECK(b_cnt == 1) CHECK(c_cnt == 1) @@ -136,21 +128,17 @@ TEST("graph", function() 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) + local a, b, c = node(root), node(root), node(root) function c.effect(x) - track(a) - track(b) + push_child_to_scope(a) + push_child_to_scope(b) return not x end - open_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); close_scope() + push_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); pop_scope() - update(a) + update_descendants(a) CHECK(#get_children(a) == 1) CHECK(#get_children(b) == 1) @@ -159,13 +147,13 @@ TEST("graph", function() do CASE "case 1" -- construct graph - local items = node { "a", "b" } - local selected = node "a" + local items = node(nil, { "a", "b" }) + local selected = node(nil, "a") local root = scope() - local scope1 = scope() - local scope2 = scope() + local scope1 = scope(root) + local scope2 = scope(root) local items_updated @@ -180,41 +168,36 @@ TEST("graph", function() end) end - do open_scope(root) + do push_scope(root) clean "root" - items_updated = node() - track(items_updated) -- should not + items_updated = node(root) + push_child_to_scope(items_updated) -- should not - set_owner(items_updated, root) - do open_scope(items_updated) - track(items) + do push_scope(items_updated) + push_child_to_scope(items) - do open_scope(root) - set_owner(scope1, root) - do open_scope(scope1) + do push_scope(root) + do push_scope(scope1) clean "scope1" - bind1 = node() + bind1 = node(scope1) - set_owner(bind1, scope1) - do open_scope(bind1) + do push_scope(bind1) clean "bind1" - track(selected) - close_scope() end - close_scope() end + push_child_to_scope(selected) + pop_scope() end + pop_scope() end - set_owner(scope2, root) - do open_scope(scope2) + do push_scope(scope2) clean "scope2" - bind2 = node() - set_owner(bind2, scope2) - do open_scope(bind2) + bind2 = node(scope2) + do push_scope(bind2) clean "bind2" - track(selected) - close_scope() end - close_scope() end - close_scope() end - close_scope() end - close_scope() end + push_child_to_scope(selected) + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end -- verify graph @@ -235,8 +218,8 @@ TEST("graph", function() do local c = get_children(selected) CHECK(#c == 2) - CHECK(table.find(c, bind1)) - CHECK(table.find(c, bind2)) + CHECK(table.find(c, bind1 :: any)) + CHECK(table.find(c, bind2 :: any)) end do @@ -267,7 +250,7 @@ TEST("graph", function() end do CASE "nodes garbage collection" - local wref = weak { node(1) } + local wref = weak { node(nil, 1) } destroy(wref[1]) gc() CHECK(not wref[1]) @@ -294,30 +277,23 @@ TEST("graph", function() ^ depth=1 - _, _ <- attempt to update nothing + _, _ <- attempt to update_descendants nothing ^ ]] - local a, b, c, d, e, f = node(), node(), node(), node(), node(), node() - local root = node() - set_owner(a, root) - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) - set_owner(e, root) - set_owner(f, root) + local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(root) function b.effect(x) - update(d) + update_descendants(d) return not x end - add_child(a, b); add_child(a, c) - add_child(d, e); add_child(d, f) + push_child(a, b); push_child(a, c) + push_child(d, e); push_child(d, f) - update(a) + update_descendants(a) CHECK(true) end @@ -1924,7 +1900,7 @@ TEST("read()", wrap_root(function() CHECK(read(src) == 1) end - do CASE "track source" + do CASE "push_child_to_scope source" local src = source(0) local count = 0 @@ -2285,6 +2261,27 @@ TEST("strict", wrap_root(function() src(not src()) CHECK(count == 4) end + + do CASE "effect using derived source" + local input = source(true) + + local output = derive(function() + return input() + end) + + local count = 0 + + effect(function() + output() + count += 1 + end) + + CHECK(count == 2) + + input(false) + + CHECK(count == 4) + end end)) local ok = FINISH()