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/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9c22f03 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: release + +on: + release: + types: [published] + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: build + run: rojo build default.project.json -o build.rbxm + + - name: release + run: gh release upload ${{github.event.release.tag_name}} build.rbxm + + + \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 10f047c..5bb1717 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,6 @@ name: site-deploy on: - push: - branches: [main] # todo: remove later - workflow_dispatch: permissions: @@ -24,21 +21,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v4 - name: Install dependencies run: npm install - name: Build with VitePress run: npm run docs:build - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist @@ -52,4 +49,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a6cd4dc..138f29b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -8,13 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Luau zip - uses: robinraju/release-downloader@v1.6 + uses: robinraju/release-downloader@v1.9 with: repository: Roblox/luau - latest: true + tag: "0.679" fileName: luau-ubuntu.zip out-file-path: bin diff --git a/.github/workflows/wallypesde.yml b/.github/workflows/wallypesde.yml new file mode 100644 index 0000000..0c705b0 --- /dev/null +++ b/.github/workflows/wallypesde.yml @@ -0,0 +1,54 @@ +name: publish to wally and pesde + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + wally: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: login + run: wally login --token "${{ secrets.WALLY_TOKEN }}" + + - name: publish + run: wally publish + + pesde: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: setup pesde + run: pesde install + + - name: login + run: pesde auth login --token "${{ secrets.PESDE_TOKEN }}" + + - name: publish + run: pesde publish --yes + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index bd0480d..18f0f88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ .vscode _local -aftman.toml sourcemap.json docs/.vitepress/dist docs/.vitepress/cache docs/node_modules docs/package-lock.json + +luau_packages/ +lune_packages/ +.pesde/ + +wally.lock +pesde.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 2127a86..3e584d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,122 @@ 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 +-------------------------------------------------------------------------------- ---- +## [0.4.1] - 2026-07-11 + +### Changed + +- `branch()` is now allowed to be used within a reactive scope. + +### Deprecated + +- `create()` overloads. Supported is now only `create(class)(props)`. + +### Fixed + +- `create()` types in the new solver should now work without `::`. + +-------------------------------------------------------------------------------- + +## [0.4.0] - 2026-01-17 + +### Added + +- `create("ClassName", { props })` and `create(Instance, { props })` syntax. +- `cleanup()` now accepts `thread` types. +- Implicit effects to set children can now recursively create more implicit + effects to set children. +- `spring()` returns a second value, a setter to set position, velocity and + impulse. +- Improved `spring()` updating and unscheduling. +- `show()` now receives a source to its callback returning the current value + of the condition. +- Ignore `false` passed as a child. +- Flag `vide.defaults` to disable the setting of default properties. +- Delayed scope destruction for control flow functions: `show()` `switch()` `indexes()` `values()`. +- Better `create()` types for the new type solver. + +### Changed + +- A scope can no longer be destroyed while it is active. Strict mode will check + for this. +- Implicit effects to set children now unparent all children when the effect is + destroyed. +- Error reporting should be improved with better formatting when effects invoke + other effects and no more loss of stack traces. +- Nesting parent properties now work, and they are now also checked for + duplicates like other properties. + +### Removed + +- Aggregate construction when setting properties with `create()`. + +-------------------------------------------------------------------------------- + +## [0.3.1] - 2024-10-09 + +### Added + +- Context functions now also return results. +- `version` table with current version. + +-------------------------------------------------------------------------------- + +## [0.3.0] - 2024-10-06 + +### Added + +- `context()`. + +### Changed + +- `root()` now returns its destructor as the first value by default. + +### Fixed + +- Error stack traces being lost. +- `root()` now destroys the scope automatically if an error occurs during call. + +-------------------------------------------------------------------------------- + +## [0.2.0] - 2023-11-22 + +### Added + +- Batched updates with `batch()`. + +### Changed + +- Improved graph updating algorithm. +- 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 + rerun before being destroyed. +- Some properties not being applied when `create()` is used recursively. + +-------------------------------------------------------------------------------- + +## [0.1.1] - 2023-09-30 + +### 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. +- `changed()` will also run its callback with the initial property value. + +### Fixed + +- `show()` and `switch()` not updating when in strict mode. + +-------------------------------------------------------------------------------- ## [0.1.0] - 2023-09-20 diff --git a/README.md b/README.md index 8ce077b..13e41b6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ for a quick introduction to the library. ## Code sample -```lua +```luau local create = vide.create local source = vide.source diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 18e36cc..37918e8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,67 +1,83 @@ -import { defineConfig } from "vitepress" - -// https://vitepress.dev/reference/site-config -export default defineConfig({ - title: "Vide", - titleTemplate: ":title - A reactive UI library for Luau", - description: "A reactive UI library for Luau.", - base: "/vide/", - head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], - - themeConfig: { - logo: "/logo.svg", - - // https://vitepress.dev/reference/default-theme-config - nav: [ - { text: "Home", link: "/" }, - { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, - { text: "API", link: "/api/reactivity-core"}, - ], - - sidebar: { - "/api/": [ - { - text: "API", - items: [ - { text: "Reactivity: Core", link: "/api/reactivity-core" }, - { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, - { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, - { text: "Element Creation", link: "/api/creation" }, - { text: "Animation", link: "/api/animation" }, - { text: "Strict Mode", link: "/api/strict-mode" }, - ] - } - ], - - "/tut/": [ - { - text: "Crash Course", - items: [ - { 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: "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: "Advanced Reactivity", - items: [ - { text: "Reactive Scopes", link: "/tut/advanced/reactive-scoping.md"} - ] - } - ], - }, - - socialLinks: [ - { icon: "github", link: "https://github.com/centau/vide" } - ] - } -}) +//import { defineConfig } from "vitepress" +import { withMermaid } from "vitepress-plugin-mermaid"; + +// https://vitepress.dev/reference/site-config +export default withMermaid({ + title: "Vide", + titleTemplate: ":title - A reactive UI library for Luau", + description: "A reactive UI library for Luau.", + base: "/vide/", + head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], + + themeConfig: { + logo: "/logo.svg", + + search: { + provider: "local" + }, + + footer: { + message: 'Released under the MIT License.', + }, + + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: "Home", link: "/" }, + { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, + { text: "API", link: "/api/reactivity-core"}, + ], + + sidebar: { + "/api/": [ + { + text: "API", + items: [ + { text: "Reactivity: Core", link: "/api/reactivity-core" }, + { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, + { text: "Reactivity: Dynamic Scoping", link: "/api/reactivity-dynamic" }, + { text: "Element Creation", link: "/api/creation" }, + { text: "Animation", link: "/api/animation" }, + { text: "Strict Mode", link: "/api/strict-mode" }, + ] + } + ], + + "/tut/": [ + { + text: "Crash Course", + items: [ + { 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: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Scopes", link: "/tut/crash-course/6-scope" }, + { text: "Reactive Components", link: "/tut/crash-course/7-reactive-component" }, + { 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: "Dynamic Scopes", link: "/tut/crash-course/11-dynamic-scopes" }, + { 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" } + ] + }, + { + text: "Advanced", + items: [ + { text: "Dynamic Scopes", link: "/tut/advanced/dynamic-scopes"} + ] + }, + { + text: "Design Patterns", + items: [ + ] + } + ], + }, + + socialLinks: [ + { icon: "github", link: "https://github.com/centau/vide" } + ] + } +}) diff --git a/docs/.vitepress/theme/index.css b/docs/.vitepress/theme/index.css new file mode 100644 index 0000000..9c3ca9a --- /dev/null +++ b/docs/.vitepress/theme/index.css @@ -0,0 +1,25 @@ +.home > * > .VPNavBar:not(.top) { + backdrop-filter: blur(0.5rem); + background-color: transparent !important; +} + +.home > * > .VPNavBar > .divider { + opacity: 0; +} + +.home > .VPContent { + display: flex; + justify-content: center; + flex-direction: column; +} + +.VPHome { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.VPBadge a { + text-decoration: none; + color: inherit + } + \ No newline at end of file diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js index b8b9aa6..54a4c62 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -1,4 +1,8 @@ -// .vitepress/theme/index.js -import DefaultTheme from 'vitepress/theme' -import './vars.css' -export default DefaultTheme +// .vitepress/theme/index.js +import DefaultTheme from 'vitepress/theme' +import './vars.css' +import './index.css' + +export default { + extends: DefaultTheme, +} diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css index 64df54f..7fbf3b5 100644 --- a/docs/.vitepress/theme/vars.css +++ b/docs/.vitepress/theme/vars.css @@ -1,3 +1,52 @@ -:root { - --vp-c-brand-1: #3086ff; -} +/* Colors */ + +:root { + --vp-c-brand-1: #3086ff; + --vp-c-brand-2: #75aeff; + + --vp-button-brand-bg: #3661a2; + --vp-button-brand-hover-bg: #24447f; + --vp-button-brand-press-bg: #4896f3; + + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient( + 120deg, + #3661a2, + #4896f3 + ); + --vp-home-hero-logo-background: -webkit-linear-gradient( + 120deg, + #3661a2, + #4896f3 + ); + + --vp-home-hero-image-filter: blur(96px); + + --vp-c-bg: #f2f5f8; + --vp-c-bg-alt: #dfe8f5; + --vp-c-bg-elv: #dde7f4; + --vp-c-bg-soft: #e8f1fe; + + --vp-c-border: #c0c3c6; + --vp-c-divider: #dfe2e6; + --vp-c-gutter: #dfe2e6; + --vp-plugin-tabs-tab-bg: var(--vp-c-bg); + + --vp-badge-info-bg: #122d26; + --vp-badge-info-text: #6bdbbd; + --vp-badge-tip-bg: #132741; + --vp-badge-tip-text: #70abfa; +} + +.dark { + --vp-c-brand-2: #234782; + --vp-c-bg: #0d131b; + --vp-c-bg-alt: #111720; + --vp-c-bg-elv: #182231; + --vp-c-bg-soft: #182231; + + --vp-c-border: #111720; + --vp-c-divider: #1d273c; + --vp-c-gutter: #181d27; +} + diff --git a/docs/api/animation.md b/docs/api/animation.md index 16e48d7..ca06b39 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -1,25 +1,30 @@ -# Animation API +# Animation -## spring() +## spring() REACTIVE Returns a new source with a value always moving torwards the input source value. - **Type** - ```lua + ```luau function spring( source: () -> T & Animatable, period: number = 1, damping_ratio: number = 1 - ): () -> T + ): (() -> T, SpringControl) type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect + + type SpringControl = ({ + position: T?, + velocity: T?, + impulse: T? + }) -> () ``` - **Details** - The output source value is updated every step based on the input source - value. + Creates a reactive scope internally to detect source updates. The movement is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). @@ -39,3 +44,7 @@ Returns a new source with a value always moving torwards the input source value. You can change when the solver runs by calling `vide.step(dt)`, which will advance the simulation time by `dt` seconds and automatically stop the solver running in heartbeat. + + ::: warning + Large periods or damping ratios can break the spring. + ::: diff --git a/docs/api/creation.md b/docs/api/creation.md index f5b4c1c..578b10f 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -1,38 +1,4 @@ -# Element Creation API - -
- -## mount() - -Runs a function and applies its result to a target instance. - -- **Type** - - ```lua - function mount(component: () -> T, target: Instance?): () -> () - ``` - -- **Details** - - The result of the function is applies to the target in the same way - properties are using `create()`. - - The function is ran in a new reactive scope, just like - [root()](reactivity-core.md#root). - - Returns a function that when called will destroy the reactive scope. - -- **Example** - - ```lua - local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "Vide" } - } - end - - mount(App, game.StarterGui) - ``` +# Element Creation ## create() @@ -40,11 +6,11 @@ Creates a new UI element, applying any given properties. - **Type** - ```lua + ```luau function create(class: string): (Properties) -> Instance function create(instance: Instance): (Properties) -> Instance - type Properties = Map + type Properties = Map ``` - **Details** @@ -60,86 +26,68 @@ 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 function:** create effect to update children + - **value is instance:** set instance as child - **Example** Basic element creation. - ```lua - local frame = create "Frame" { - Name = "NewFrame", - Position = UDim2.fromScale(1, 0) + ```luau + local frame = create "TextButton" { + Name = "Button", + Size = UDim2.fromOffset(200, 160), + + Activated = function() + print "clicked" + end, + + create "UICorner" {} } ``` - A component using property nesting. - - ```lua - type Layout = { - Layout = { - Position: UDim2?, - Size: UDim2?, - AnchorPoint: Vector2? - } - } - - type Children = { - Children = Array - } - - function Background(props: Layout & Children & { - Color: Color3 - }) - return create "Frame" { - BackgroundColor3 = props.Color, - props.Layout, - props.Children - } - end - ``` - ## action() -Creates a callback that can be passed to `create()` to invoke custom actions on -instances. +Creates a special object that can be passed to `create()` to invoke custom +actions on instances. - **Type** - ```lua + ```luau function action((Instance) -> (), priority: number = 1): Action ``` - **Details** - When passed to `create()`, the given callback is called with the instance - being created as the only argument. Actions take precedence over property - and child assignments. + When passed to `create()`, the function is called with the instance being + created as the only argument. Actions take precedence over property and + child assignments. A priority can be optionally specified to ensure certain actions run after - other actions. Higher priority numbers are ran after lower priority numbers. + other actions. Lower priority values are ran first. - **Example** An action to listen to changed properties: - ```lua - local function changed(property: string, callback: (new) -> ()) + ```luau + local function changed(property: string, fn: (new) -> ()) return action(function(instance) - instance:GetPropertyChangedSignal("property"):Connect(function() - callback(instance[property]) + local cn = instance:GetPropertyChangedSignal(property):Connect(function() + fn(instance[property]) + end) + + -- disconnect on scope destruction to allow gc of instance + cleanup(function() + cn:Disconnect() end) end) end @@ -147,7 +95,58 @@ instances. local output = source "" create "TextBox" { - -- will update the `output` source anytime the text property is changed + -- will update the output source anytime the text property is changed changed("Text", output) } ``` + +## changed() + +A wrapper for `action()` to listen for property changes. + +- **Type** + + ```luau + function changed(property: string, fn: (unknown) -> ()): Action + ``` + +- **Details** + + Will run the given function immediately and whenever the property updates. + + The function is called with the updated property value. + + Runs with an action priority of 1. + +## mount() STABLE + +Runs a function in a new stable scope and optionally applies its result to a +target instance. + +- **Type** + + ```luau + function mount(component: () -> T, target: Instance?): () -> () + ``` + +- **Details** + + This is a utility for `root()` when parenting a component to an existing + instance. + + The result of the function is applied to a target in the same way + properties are using `create()`. + + Returns a function that when called will destroy the stable scope. + +- **Example** + + ```luau + local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "Vide" } + } + end + + local destroy = mount(App, game.StarterGui) + ``` diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 0e4a5d0..ebbc5dd 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -1,132 +1,155 @@ -# Reactivity API: Core +# Reactivity: Core -
+## Scopes -## root() +Vide code can run in one of two scopes: STABLE or REACTIVE. -Creates and runs a function in a new reactive scope. +- Reactive scopes rerun if a source read within updates. +- Stable scopes never rerun. +- Reactive scopes cannot be created directly within another reactive scope. +- When a scope is destroyed, all scopes created within are also destroyed. + +Different functions in Vide's API will run code in different scopes. + +:::warning +Yielding is not allowed in any stable or reactive scope. Strict mode will check +for this. +::: + +## root() STABLE + +Runs a function in a new stable scope. - **Type** - ```lua - function root(fn: (destroy: () -> ()) -> T...): T... + ```luau + function root(fn: (Destructor) -> T...): (Destructor, T...) + + type Destructor = () -> () ``` - **Details** - Creates a new root reactive scope, where creation and derivations of sources - can be tracked and properly disposed of. - - Returns the result of the given function. - - A function to destroy the root is passed into the callback, which will run - any cleanups and allow derived sources created to garbage collect. - - ::: warning - `fn()` cannot yield. - ::: + Returns a destructor and any values returned by the callback. ## source() -Creates a new source with the given value. +Creates a new source. - **Type** - ```lua - function source(value: T): (T?) -> T + ```luau + function source(value: T): Source + + type Source = + () -> T -- get + & (T) -> () -- set ``` - **Details** - 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. + Call the returned source with no argument to read its value. + Call the returned source with an argument to set its value. - **Example** - ```lua + ```luau local count = source(0) - - count() -- 0 - - count(count() + 1) -- 1 + print(count())-- 0 + count(count() + 1) + print(count()) -- 1 ``` -## effect() +## effect() REACTIVE -Runs a side-effect on source update. +Runs a function in a new reactive scope. - **Type** - ```lua - function effect(callback: () -> ()) + ```luau + function effect(fn: () -> ()) ``` - **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 function is ran once immediately. - **Example** - ```lua - local num = source(1) + ```luau + local count = source(1) effect(function() - print(num()) + print(count()) end) -- prints 1 - num(num() + 1) + count(2) -- prints 2 ``` -## derive() +## derive() REACTIVE -Derives a new source from existing sources. +Runs a function in a new reactive scope to compute a value for new source. - **Type** - ```lua - function derive(source: () -> T): () -> T + ```luau + function derive(fn: () -> T): () -> T ``` - **Details** - The derived source will have its value recalculated when any source source - it derives from is updated. + Anytime the reactive scope reruns, the output source value is set to what is + returned. - 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 function is ran once immediately. - **Example** - ```lua + ```luau local count = source(0) local text = derive(function() return `count: {count()}` end) - text() -- "count: 0" + print(text()) -- "count: 0" count(1) - text() -- "count: 1" + print(text()) -- "count: 1" ``` --------------------------------------------------------------------------------- + A `derive()` should be used instead of a pure function when you expect it to + be read multiple times between updates, because `derive()` will cache the + result to prevent recomputing it on every read. + + ::: code-group + + ```luau [Pure Function] + local count = source(0) + + local text = function() + print "ran" + return `count: {count()}` + end + + count(1) + print(text()) -- prints "ran" followed by "count: 1" + print(text()) -- prints "ran" followed by "count: 1" + ``` + + ```luau [Derived Source] + local count = source(0) + + local text = derive(function() -- [!code highlight] + print "ran" + return `count: {count()}` + end) -- [!code highlight] + + count(1) -- prints "ran" + print(text()) -- prints "count: 1" + print(text()) -- prints "count: 1" + ``` + + ::: diff --git a/docs/api/reactivity-dynamic.md b/docs/api/reactivity-dynamic.md new file mode 100644 index 0000000..f0b3229 --- /dev/null +++ b/docs/api/reactivity-dynamic.md @@ -0,0 +1,229 @@ +# Reactivity: Dynamic Scopes + +Dynamic scopes are scopes that are created or destroyed in response to +source updates. Vide provides functions for some common use-cases for dynamic scopes. + +## show() REACTIVE + +Shows a component if the source is truthy. Optionally shows a fallback component +if the source is falsey. + +- **Type** + + ```luau + function show(source: () -> unknown, component: Constructor): () -> T? + function show(source: () -> unknown, component: Constructor, fallback: () -> U): () -> T | U + + type Constructor = () -> (T, number?) + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + The component is run in a stable scope when truthy, otherwise the stable + scope is destroyed. + + Returns a source holding an instance of the currently shown component or + `nil` if no component is currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + +## switch() REACTIVE + +Shows one of a set of components depending on a source and a mapping table. + +- **Type** + + ```luau + function switch(source: () -> K): (map: Map>): () -> V? + + type Constructor = () -> (T, number?) + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + When the source updates, its value is inputted into a map to get a component + constructor. This component is then run in a stable scope. The previous + stable scope is destroyed. + + Returns a source holding an instance of the currently shown component or + `nil` if no component is currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + +- **Example** + + ```luau + local logged = source(false) + + local button = switch(logged) { + [true] = function() + return Button { Text = "Log out", Toggle = logged } + end, + + [false] = function() + return Button { Text = "Log in", Toggle = logged } + end + } + ``` + +## indexes() REACTIVE + +Shows a component for each index in a table. + +- **Type** + + ```luau + function indexes( + source: () -> Map, + constructor: (value: () -> VI, index: KI) -> (VO, number?) + ): Array + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + When the source table updates, a component is generated for each index in + the table. + + - For any added index, the `constructor` function is run in a new stable + scope to produce an instance that is cached. + - For any removed index, the stable scope for that index is destroyed. + + The `constructor` function is called with: + + 1. A *source containing the index's value*. + 2. The *index itself*. + + Anytime an existing index's value changes, the `constructor` function is not + rerun, instead, that index's corresponding source is updated with the new + value. + + Returns a source holding an array of instances currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + +- **Example** + + ```luau + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = indexes(items, function(item, i) + return ItemDisplay { + Name = function() + return i .. ": " .. item().name + end, + + Image = function() + return "rbxassetid://" .. item().icon + end, + } + end) + ``` + +## values() REACTIVE + +Shows a component for each value in a table. + +- **Type** + + ```luau + function values( + source: () -> Map, + constructor: (value: VI, index: () -> KI) -> (VO, number?) + ): Array + +- **Details** + + Operates with the same idea as `indexes()`, but applied to values instead of + indexes. + + Creates a reactive scope internally to detect source updates. + + When the source table updates, a component is generated for each value in + the table. + + - For any added value, the `constructor` function is run in a new stable scope + to produce an instance that is cached. + - For any removed value, the stable scope for that value is destroyed. + + The `constructor` function is called with: + + 1. The *value itself*. + 2. A *source containing the value's index*. + + Anytime an existing value's index changes, the `constructor` function is not + rerun, instead, that value's corresponding source is updated with the new + index. + + Returns a source holding an array of instances currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + + ::: warning + Having the same values appear multiple times in the input source table can + cause unexpected behavior. Strict mode has checks for this. + ::: + +- **Example** + + ```luau + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = values(items, function(item, i) + return ItemDisplay { + Name = function() + return i() .. ": " .. item.Name + end + + Image = "rbxassetid://" .. item.icon, + } + end) + ``` + +- **Extra** + + When should you use `indexes()` and `values()`? + + `values()` should be used when you have a fixed set of objects where the + same objects can be re-arranged in the source table. It maps a value to a + UI element. + + e.g. + - List of all players. + - Inventory of items. + - Chat message history. + - Toast notifications. + + `indexes()` should be used in other cases, especially when your source table + has primitive values. It maps an index to a UI element. + + e.g. + - List of character or weapon stats. + + In most cases, both functions will produce the same observed result. + The main difference is performance, picking the right function to use can + 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 source index can be used to animate a change in position for the UI + element. + +-------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md deleted file mode 100644 index 47dafa8..0000000 --- a/docs/api/reactivity-flow.md +++ /dev/null @@ -1,218 +0,0 @@ -# Reactivity API: Control Flow - -
- -## 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() - -Shows one of a set of components depending on an input source and a mapping table. - -- **Type** - - ```lua - function switch(source: () -> K): (map: Map V>) -> V? - ``` - -- **Details** - - 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 - 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** - - ```lua - local logged = source(false) - - local button = switch(logged) { - [true] = function() - return Button { Text = "Log out", Toggle = logged } - end, - - [false] = function() - return Button { Text = "Log in", Toggle = logged } - end - } - ``` - -## indexes() - -Maps each index in a table source to an object. - -- **Type** - - ```lua - function indexes( - source: () -> Map, - transform: (value: () -> VI, index: KI) -> VO - ): Array - -- **Details** - - 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. - - 1. First argument is a *source containing the index's value*. - 2. Second argument is the *index itself*. - - 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. - -- **Example** - - The intended purpose of this function is to map each index in a table to - a UI element. - - ```lua - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = indexes(items, function(item, i) - return ItemDisplay { - Name = function() - return i .. ": " .. item().name - end, - - Image = function() - return "rbxassetid://" .. item().icon - end, - } - end) - ``` - -## values() - -Maps each value in a table source to an object. - -- **Type** - - ```lua - function values( - source: () -> Map, - transform: (value: VI, index: () -> KI) -> VO - ): Array - -- **Details** - - Returns a source holding an array of instances currently shown. - - 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. - - ::: warning - 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** - - The intended purpose of this function is to map each value in a table to - a UI element. - - ```lua - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = values(items, function(item, i) - return ItemDisplay { - Name = function() - return i() .. ": " .. item.Name - end - - Image = "rbxassetid://" .. item.icon, - } - end) - ``` - -- **Extra** - - When should you use `indexes()` and `values()`? - - `values()` should be used when you have a fixed set of objects where the - same objects can be re-arranged in the source table. It maps a value to a - UI element. - - e.g. - - List of all players. - - Inventory of items. - - Chat message history. - - Toast notifications. - - `indexes()` should be used in other cases, especially when your source table - has primitive value. It maps an index to a UI element. - - e.g. - - List of character or weapon stats. - - In most cases, both functions will appear to have the same behavior. - The main difference is performance, picking the right function to use can - result in less property updates and less re-renders. 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 9219b29..b8bff4d 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -1,47 +1,58 @@ -# Reactivity API: Utility +# Reactivity: Utility ## cleanup() -Runs a callback anytime a reactive scope is re-ran. +Queues a callback to run when a scope is reran or destroyed. - **Type** - ```lua - function cleanup(callback: () -> ()) + ```luau + function cleanup(v: Function | Disconnectable | Destroyable | thread) + + type Function = () -> () + type Destroyable = { destroy: () -> () } + type Disconnectable = { disconnect: () -> () } ``` - **Example** - ```lua - local data = source(1) + ```luau + local count = source(0) - effect(function() - local label = create "TextLabel" { Text = data() } + local destroy = root(function() + effect(function() + count() - cleanup(function() - label:Destroy() + cleanup(function() + print "cleaned" + end) end) - end) + end + + -- nothing printed yet + count(1) -- prints "cleaned" + count(2) -- prints "cleaned" + destroy() -- prints "cleaned" ``` -## untrack() +## untrack() STABLE -Runs a given function where any sources read will not track its reactive scope. +Runs a function in a new stable scope. - **Type** - ```lua + ```luau function untrack(source: () -> T): T ``` - **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** - ```lua + ```luau local a = source(0) local b = source(0) @@ -50,9 +61,9 @@ Runs a given function where any sources read will not track its reactive scope. end) print(sum()) -- 0 - b(1) + b(1) -- untracked so reactive scope created by derive() does not rerun print(sum()) -- 0 - a(1) + a(1) -- reactive scope created by derive() reruns print(sum()) -- 2 ``` @@ -62,8 +73,86 @@ Utility used to read a value that is either a primitive or a source. - **Type** - ```lua + ```luau function read(value: T | () -> T): T ``` --------------------------------------------------------------------------------- +## batch() + +Runs a function where any source updates made within the function do not +trigger effects until after the function ends. + +- **Type** + + ```luau + function batch(fn: () -> ()) + ``` + +- **Details** + + Improves performance when an effect depends on multiple sources, and those + sources need to be updated. + +- **Example** + + ```luau + local a = source(0) + local b = source(0) + + effect(function() + print(a() + b()) + end) + + -- prints "0" + + batch(function() + a(1) -- no print + b(2) -- no print + end) + + -- prints "3" + ``` + +## context() STABLE + +Creates a new context. + +- **Type** + + ```luau + function context(default: T): Context + + type Context = + () -> T -- get + & (T, () -> U) -> U -- set + ``` + +- **Details** + + Calling `context()` returns a new context function. + Call this function with no arguments to get the context value. + Call this function with a value and a function to create a new context with + the given value. + + The new context is run under a stable scope. + +- **Example** + + ```luau + local theme = context() + + local function Button() + print(theme()) + end + + root(function() + theme("light", function() + Button() -- prints "light" + + theme("dark", function() + Button() -- prints "dark" + end) + end) + end) + ``` + diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 35de4d8..6f9ee80 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -2,7 +2,7 @@ Strict mode is library-wide and can get set by doing: -```lua +```luau vide.strict = true ``` @@ -14,24 +14,24 @@ and identifying improper usage. Currently, strict mode will: -1. Run derived sources twice a source updates. -2. Run effects twice when a source updates. -3. Throw an error if yields occur where they are not allowed. -4. Checks for `indexes()` and `values()` returning primitive values. -5. Checks for `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. +1. Run reactive scopes twice when a source updates. +2. Throw an error if yields occur where they are not allowed. +3. Checks for `indexes()` and `values()` outputting primitive values. +4. Checks for `values()` input having duplicate values. +5. Checks for duplicate nested properties at same depth. +6. Checks for destruction of an active scope. +7. Better error reporting and stack traces. -By rerunning sources and effects, any side-effects are made more apparent. -This also helps ensure that cleanups are being handled correctly. +By rerunning reactive scopes twice each time they update, it helps ensure that +computations are pure, and that any cleanup is 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 -when binding properties to sources. +when implicit effects are created for instance property updating. -It is recommend 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 +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 do not need to worry about disabling strict mode unless you have manually enabled it. diff --git a/docs/index.md b/docs/index.md index 6fe3a4a..295d137 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,14 +1,16 @@ --- # https://vitepress.dev/reference/default-theme-home-page layout: home +pageClass: home +next: + text: 'Introduction' + link: '/tut/crash-course/1-introduction' hero: - name: Vide - text: "" + name: "Vide" tagline: A reactive UI library for Luau. image: src: /logo.svg - alt: Vide actions: - theme: brand text: Tutorials @@ -17,8 +19,4 @@ hero: text: API Reference link: /api/reactivity-core -features: - - title: In Development - details: Not recommended for production use. --- - diff --git a/docs/package.json b/docs/package.json index 921ee39..a329007 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,13 +1,12 @@ -{ - "type": "module", - - "scripts": { - "docs:dev": "vitepress dev", - "docs:build": "vitepress build", - "docs:preview": "vitepress preview" - }, - - "devDependencies": { - "vitepress": "^1.0.0-rc.4" - } -} +{ + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.4.5", + "vitepress-plugin-mermaid": "2.0.17" + } +} diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg index 85488e0..1962800 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 92b06c1..22d840a 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,37 +1,31 @@ - - - - - + + + + + - + + - - - + + + - + - - - + + + - - - - - - - - + - + - + diff --git a/docs/tut/advanced/dynamic-scopes.md b/docs/tut/advanced/dynamic-scopes.md new file mode 100644 index 0000000..139a19c --- /dev/null +++ b/docs/tut/advanced/dynamic-scopes.md @@ -0,0 +1,148 @@ +# Dynamic Scopes + +Dynamic scopes are scopes that are created and destroyed in response to +source updates. This is needed for conditionally rendering parts of your UI, +such as opening and closing menus. + +While Vide provides functions for common ways to do this, this section will +show how you can implement them yourself so you are not limited by only what is +provided. + +## Recreating [`show()`](/api/reactivity-dynamic#show-reactive) + +The most basic one, `show()`, can be +implemented yourself like so: + +```luau +local function show(toggle: () -> unknown, component: () -> Instance) + return derive(function() + return if toggle() then untrack(component) else nil + end) +end +``` + +The main thing to note here is the use of `untrack()`. This function runs its +callback in a new stable scope. Without this, if the component were to create +a reactive scope, an error would occur since a reactive scope cannot be created +within a reactive scope. + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#444455", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#444455" + } +}}%% + +graph + +subgraph derive ["derive (reactive)"] + + subgraph untrack ["untrack (stable)"] + subgraph effect ["effect (reactive)"] + + end + end +end +``` + +You can see from the above graph how the effect would not be created directly +inside the derive, there is a stable scope between them. This requirement exists +as a guard against unintentional rerendering of UI. + +## Recreating [`switch()`](/api/reactivity-dynamic#switch-reactive) + +```luau +local function switch(key) + return function(map) + return derive(function() + local component = map[key()] + return if component then untrack(component) else nil + end) + end +end +``` + +## Recreating [`indexes()`](/api/reactivity-dynamic#indexes-reactive) + +This is a more complicated function because it manages multiple scopes at the +same time, unlike the previous functions. Because some scopes may persist +between reruns, we cannot use `untrack()` anymore which automatically destroys +on rerun; we must use `root()` where the lifetime of each scope is managed +manually and independently. + +```luau +local function indexes( + input: () -> Map, + transform: (value: () -> VI, index: I) -> VO +) + local index_caches = {} :: Map VI, + destroy: () -> () + }?> + + -- destroy all scopes if the parent scope is destroyed + cleanup(function() + for _, cache in index_caches do + assert(cache).destroy() + end + end) + + return derive(function() + local new_input = input() + + -- destroy scopes of removed indexes + for i, cache in index_caches do + if new_input[i] == nil then + assert(cache).destroy() + index_caches[i] = nil + end + end + + -- create scopes or update sources of added or changed index values + for i, v in new_input do + local cache = index_caches[i] + + if cache == nil then -- no scope created for this index, create one + local src = source(v) + + local destroy, result = root(function() + return transform(src, i) + end) + + index_caches[i] = { + destroy = destroy, + source = src, + output = result, + previous_input = v + } + elseif cache.previous_input ~= v then -- scope exists, update source + cache.previous_input = v + cache.source(v) + else -- scope exists and value has not changed; do nothing + end + end + + -- return the cached output values as an array + local array = table.create(#index_caches) + + for _, cache in index_caches do + table.insert(array, assert(cache).output) + end + + return array + end) +end +``` + +-------------------------------------------------------------------------------- + +Though the above functions are already provided to you by Vide, this serves as +an example for how you may create your own dynamic scope functions. diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md deleted file mode 100644 index 5b983ab..0000000 --- a/docs/tut/advanced/reactive-scoping.md +++ /dev/null @@ -1,61 +0,0 @@ -# Reactive Scoping - -This is a brief document designed to give the user more insight into how Vide's -reactive graph works. - -Each time you create and derive sources, a new node representing that source is -created and added to the reactive graph. Each node stores a value and a -side-effect function. Each node also keeps track of its parents and children, -as well as any cleanups registered. - -Any time a node is updated, Vide will traverse and update that node's children, -its children's children, etc, until all nodes descending from that node has been -updated. Traversal will stop at a node if that node's cached value does not -change after an update. - -For every node that is updated, a scope is opened for that node. These scopes -are referred to as "reactive scopes". Any source read from within a node's scope -will that node as a child. This is similar to cleanups, anytime a cleanup is -registered, it is added to the node of the currently active scope. - -The way Vide tracks reactive scopes, is by using a stack of nodes. The current -active reactive scope is the node at the top of this stack. - -When destroying a node, its descendents are traversed and also destroyed. -When being destroyed, a node's connections (parents and children) are cleared, -and any pending cleanup functions are ran. - -The purpose of `root()` (which is called internally by `mount()`) is to setup -the root node which will track any node created or derived inside its scope, or -any cleanups registered. Without it, nodes could be garbage collected without a -chance to run pending cleanups which can cause memory leakage. - -Control flow functions in Vide are special, as they can dynamically create and -destroy new root scopes. - -It is the combination of the above which allows us to write components like so: - -```lua -local function Counter() - local count = source(0) - - local connection = stepped:Connect(function() count(count() + 1) end) - - cleanup(function() connection:Disconnect() end) - effect(function() print(count()) end) - - return create "TextLabel" { Text = count } -end -``` - -Vide doesn't recognise this as a "component", that is a user abstraction. Vide -just sees this as a function that creates nodes in the reactive graph. - -Whenever the reactive scope that calls this function is destroyed, like by a -control flow function, the registered cleanup will be called, and the effect -(which is just a node on the reactive graph) is destroyed. The returned instance -and the bound `count` source is just considered to be a side-effect, and with -the reactive scope from which the side-effects stem from destroyed, the instance -can be garbage collected - everything is nicely cleaned up. - -> todo: add graphics diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md deleted file mode 100644 index 8628e45..0000000 --- a/docs/tut/control-flow/2-show.md +++ /dev/null @@ -1 +0,0 @@ -# show() diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md deleted file mode 100644 index 647835a..0000000 --- a/docs/tut/control-flow/3-switch.md +++ /dev/null @@ -1 +0,0 @@ -# switch() diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md deleted file mode 100644 index aad2c90..0000000 --- a/docs/tut/control-flow/4-indexes.md +++ /dev/null @@ -1 +0,0 @@ -# indexes() diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md deleted file mode 100644 index 8909904..0000000 --- a/docs/tut/control-flow/5-values.md +++ /dev/null @@ -1 +0,0 @@ -# values() diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 371950a..564aa2d 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -1,42 +1,21 @@ # 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/). ## 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. +Vide's reactive and declarative API aims to let you program UI as simply as +possible, with a strong focus on how data flows through your application. -Some of the main focuses behind Vide's design choices: +Some of Vide's main design choices: -- Concise syntax to reduce verbosity as much as possible. -- 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. +- Syntax minimal. +- Data oriented. +- Typechecking compatible. +- Instance independent. -## Structure Of A Vide App - -The entry point for all Vide apps is the `mount()` function. This function -sets up Vide's reactivity system. It takes and calls a function that should -create your entire app, and will apply its result to a target. - -In Vide, your app should be composed of functions, each function creates a -specific part of your app, and can be reused if needed. These functions are -called *components*. - -```lua -local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "hi" } - } -end - -mount(App, game.StarterGui) -``` +Vide's reactivity operates with the concept +of scopes which carries a learning curve, though is what makes Vide's minimal +syntax possible. The crash course will introduce these concepts gradually. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md new file mode 100644 index 0000000..e25d451 --- /dev/null +++ b/docs/tut/crash-course/10-cleanup.md @@ -0,0 +1,38 @@ +# Cleanup + +Sometimes you may need to do some cleanup when destroying a component or after +a side-effect from a source update. Vide provides a function `cleanup()` which +is used to queue a callback for the next time a reactive scope is rerun or +destroyed, or when a stable scope is destroyed. + +```luau +local root = vide.root +local source = vide.source +local effect = vide.effect +local cleanup = vide.cleanup + +local count = source(0) + +local destroy = root(function() + effect(function() + local x = count() + cleanup(function() print(x) end) + end) + + cleanup(function() print "root destroyed" end) +end) + +count(1) -- prints "0" +count(2) -- prints "1" +destroy() -- prints "2" and "root destroyed" +``` + +::: 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. +::: diff --git a/docs/tut/crash-course/10-property-nesting.md b/docs/tut/crash-course/10-property-nesting.md deleted file mode 100644 index 2aabb11..0000000 --- a/docs/tut/crash-course/10-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 = { - -- allows us to also optionally pass a source that returns an array of children instead - 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/11-dynamic-scopes.md b/docs/tut/crash-course/11-dynamic-scopes.md new file mode 100644 index 0000000..1c12e51 --- /dev/null +++ b/docs/tut/crash-course/11-dynamic-scopes.md @@ -0,0 +1,211 @@ +# Dynamic Scopes + +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 *dynamic scope* functions. + +These functions create and destroy scopes for you in response to source +updates. They return a source containing the created component. This source can +be parented as a child which will update the shown children whenever the source +updates. + +The simplest example is using `show()`. + +```luau +local source = vide.source +local create = vide.create +local show = vide.show +local root = vide.root + +function Button(props: { Text: string, Activated: () -> () }) + return create "TextButton" { + Text = props.Text, + Activated = props.Activated + } +end + +function Menu() + return create "TextLabel" { + Text = "This is a menu" + } +end + +function App() + local toggled = source(false) + + return create "ScreenGui" { + Button { + Text = "Toggle Menu", + Activated = function() + toggled(not toggled()) + end + }, + + show(toggled, Menu) -- [!code highlight] + } +end + +root(function() + App().Parent = game.StarterGui +end) +``` + +This is a complete example of rendering UI which has a single button that +toggles the opening of a menu. + +-------------------------------------------------------------------------------- + +Another common function is `indexes()`. This function creates a component for +each index in a table. + +Each component created is done so in a new and independent stable scope. The +indexes of the table are checked each source update to prevent redunant +destruction and recreation of UI elements. + +```luau +local source = vide.source +local create = vide.create +local indexes = vide.indexes +local root = vide.root + +local function Todo(props: { + Text: () -> string, + Position: number, + Activated: () -> () +}) + return create "TextButton" { + Text = function() return props.Position .. ": " .. props.Text() end, + LayoutOrder = props.Position, + Activated = Activated + } +end + +local function TodoList(props: { List: () -> Array }) + return create "Frame" { + create "UIListLayout" {}, + + indexes(props.List, function(text, i) -- [!code highlight] + return Todo { + Text = text, + Position = i, + Activated = function() -- remove the todo when clicked + local list = props.List() + table.remove(list, i) + props.List(list) + end + } + end) + } +end + +function App() + local list = source { + "finish the crash course", + "star Vide's GitHub" + } + + return create "ScreenGui" { + TodoList { List = list }, + } +end + +root(function() + App().Parent = game.StarterGui +end) +``` + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#111720", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" + } +}}%% + +graph + +subgraph root ["root"] + 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 +``` + +When you edit a table in a source, you must set that table again to actually +update the source. + +```luau +local src = source { 1, 2 } +local data = src() +table.insert(data, 3) -- no effects will run +src(data) -- effects will run +``` + +-------------------------------------------------------------------------------- + +All dynamic scope functions also support delaying the destruction of the scope. +This is useful for playing any sort of animation or effect before the UI +instance is removed. + +If you have the following code, for example: + +```lua +local function Menu() + return create "Frame" {} +end + +local toggled = source(true) + +create "ScreenGui" { + show(toggled, function() + return Menu {} + end) +} + +toggled(false) -- menu will disappear immediately +``` + +```lua +local function Menu(props: { Visible: () -> boolean }) + local transparency = spring(function() + return if p.Visible then 0 else 1 + end + + return create "Frame" { + BackgroundTransparency = transparency + } +end + +local toggled = source(true) + +create "ScreenGui" { + show(toggled, function(_, present) + return Menu { p.Visible = present }, 3 -- give a generous 3 seconds for the spring to complete before destroying + end) +} + +toggled(false) +-- `present` will go `false` immediately +-- transparency will begin being sprung +-- after 3 seconds the scope is destroyed, giving the spring enough time to complete +``` + +If `toggled` goes from truthy to falsey, beginning the timer, but then back +to truthy before the timer finishes, the timer is cancelled and the scope is +not destroyed. diff --git a/docs/tut/crash-course/11-actions.md b/docs/tut/crash-course/12-actions.md similarity index 57% rename from docs/tut/crash-course/11-actions.md rename to docs/tut/crash-course/12-actions.md index 79ca7c7..60e5c30 100644 --- a/docs/tut/crash-course/11-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -1,14 +1,11 @@ # 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. +Actions are special callbacks that you can pass along with properties, +to run some code on an instance receiving them. -```lua +```luau local action = vide.action -``` -```lua create "TextLabel" { Text = "test", @@ -20,24 +17,24 @@ 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 +```luau local action = vide.action +local source = vide.source +local effect = vide.effect local cleanup = vide.cleanup local function changed(property: string, callback: (new) -> ()) return action(function(instance) - local con = instance:GetPropertyChangedSignal(property):Connect(function() + local connection = 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) + cleanup(connection) end) end @@ -47,9 +44,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 by 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-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md similarity index 66% rename from docs/tut/crash-course/12-strict-mode.md rename to docs/tut/crash-course/13-strict-mode.md index 3cb09e6..0de8887 100644 --- a/docs/tut/crash-course/12-strict-mode.md +++ b/docs/tut/crash-course/13-strict-mode.md @@ -5,11 +5,15 @@ 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. +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. -```lua +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 properly. + +```luau local source = vide.source local effect = vide.effect @@ -19,6 +23,7 @@ local count = source(0) local ran = 0 effect(function() + count() ran += 1 end) diff --git a/docs/tut/crash-course/14-concepts.md b/docs/tut/crash-course/14-concepts.md new file mode 100644 index 0000000..f33d043 --- /dev/null +++ b/docs/tut/crash-course/14-concepts.md @@ -0,0 +1,113 @@ +# 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. + +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 response to a source update. + +Created with `effect()`. + +## Stable Scope + +One of the two types of Vide scopes. + +Created by: + +- `root()` +- `untrack()` +- `show()` +- `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 do track sources and will rerun when those sources update. + +Reactive scopes cannot be created within a reactive scope, but stable scopes +can be created within a reactive scope. + +## Scope Cleanup + +When a scope is rerun or destroyed, all scopes created within it are +automatically destroyed. + +Any functions queued by `cleanup()` are also ran. + +## Reactive Graph + +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. + +### Code + +```luau +local count = source(0) + +root(function() + local text = derive(function() + return "count: " .. count() + end) + + effect(function() + print(text()) + end) +end) +``` + +### Graph resulting from code + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#111720", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#202530" + } +}}%% + +graph LR + +subgraph root + text --> effect +end + +count --> text +``` + +Notes: + +- Since `count` is a source, not an effect, it can exist + outside of scopes. +- An update to `count` will cause `text` to rerun, which + then causes `effect` to rerun. +- When the root scope is destroyed, `text` and + `effect` will be destroyed alongside it, since they were created within 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 68e3007..668a470 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -2,62 +2,37 @@ 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. +Parentheses `()` can be omitted when calling functions with string or +table literals for brevity. -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 +```luau 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 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 } -} -``` - -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 3050cf8..0b803ca 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,11 +1,16 @@ # Components -Components are custom-made reusable pieces of UI made from other pieces of UI. +Vide encourages separating different parts of your UI into functions called +*components*. -By using components you can make your application more modular and better -organized. +A component is a function that creates and returns a piece of UI. -```lua [Button.luau] +This is a way to separate your UI into small chunks that you can reuse and put +together. + +::: code-group + +```luau [Button.luau] local create = vide.create local function Button(props: { @@ -15,50 +20,46 @@ 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 return Button ``` -```lua [App.luau] -local mount = vide.mount +```luau [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), - 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 } } end - -mount(App, game.StarterGui) ``` -Above is a simple example of a button component with a set color and size, -being reused across files. +::: A single parameter `props` is used to pass properties to the component. - -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. - -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 1583c59..d862156 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,12 +1,11 @@ -# 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 -can be composed and derived to create new sources of data. +Sources are special objects that store a single value and are the core of +Vide's reactivity. -A source in Vide can be created using `source()`. +A source can be created using `source()`. -```lua +```luau local source = vide.source local count = source(0) @@ -17,14 +16,13 @@ The value passed to `source()` is the initial value of the source. The value of a source can be set by calling it with an argument, and can be read by calling it with no arguments. -```lua +```luau 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 +```luau local count = source(0) local text = function() @@ -36,8 +34,5 @@ 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. +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 3891652..82768dc 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,11 +1,11 @@ -# Effect +# Effects -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 functions that are ran in response to source updates. +A source and effect is analogous to a signal and connection. Effects are created using `effect()`. -```lua +```luau local source = vide.source local effect = vide.effect @@ -20,17 +20,16 @@ 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 re-ran too. +Any source read inside an effect is tracked and will rerun the effect when +that source is updated. -The callback is first ran immediately inside the `effect()` call to initially -figure out what sources are being used. +The effect runs its callback once immediately to initially figure out what +sources are being read. -Effects also work with derived sources, it doesn't matter how deeply nested a -source is. +Derived sources are also tracked, it does not matter how deeply nested +inside a function a source is. -```lua +```luau local source = vide.source local effect = vide.effect @@ -49,27 +48,24 @@ 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. +If a source is updated with the same value it already had, it will not rerun +effects depending on it. -All observable changes to the user are considered to be side-effects of pure -computations. +You can also read from a source within an effect without the effect tracking it. -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. +```luau +local source = vide.source +local effect = vide.effect +local untrack = vide.untrack -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. +local a = source(0) +local b = source(0) -## Root Reactive Scopes +effect(function() + print(`a: {a()} b: {untrack(b)}`) +end) -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. +a(1) -- prints "a: 1 b: 0" +b(1) -- prints nothing +a(2) -- prints "a: 2 b: 1" +``` diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md new file mode 100644 index 0000000..e2cea69 --- /dev/null +++ b/docs/tut/crash-course/6-scope.md @@ -0,0 +1,88 @@ +# 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. + +There are two types of scopes: stable and reactive. + +- A scope must be created within another scope. +- Stable scopes never rerun. +- Reactive scopes can rerun. +- A reactive scope cannot be created within another reactive scope, only within + a stable scope. + +An exception to the first rule is `root()`, which creates the initial scope that +you destroy manually with a destructor function it returns. + +`root()` creates a stable scope. +`effect()` creates a reactive scope. + +Whenever a scope is destroyed, any scope created within that scope is also +destroyed, and so on. + +```luau +local root = vide.root +local source = vide.source +local effect = vide.effect + +local count = source(0) + +local function setup() + effect(function() + print(count()) + end) +end + +setup() -- error, effect() tried to create a reactive scope with no stable scope + +local destroy = root(setup) -- ok since effect() was called in a stable scope + +count(1) -- prints "1" +count(2) -- prints "2" + +destroy() + +count(3) -- reactive scope created by effect() is destroyed, it does not rerun +``` + +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": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#111720", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" + } +}}%% + +graph + +subgraph root + direction LR + count --> effect +end +``` + +When the stable `root()` scope 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-property-binding.md b/docs/tut/crash-course/7-property-binding.md deleted file mode 100644 index b3840cc..0000000 --- a/docs/tut/crash-course/7-property-binding.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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/6-stateful-component.md b/docs/tut/crash-course/7-reactive-component.md similarity index 60% rename from docs/tut/crash-course/6-stateful-component.md rename to docs/tut/crash-course/7-reactive-component.md index ba167e2..339ee4f 100644 --- a/docs/tut/crash-course/6-stateful-component.md +++ b/docs/tut/crash-course/7-reactive-component.md @@ -1,11 +1,9 @@ -# Stateful Component +# Reactive Components -A stateful component is a component that stores and displays some data. - -Stateful components in Vide are created using sources and effects - sources to +Reactive components in Vide are created using sources and effects - sources to store the data, and effects to display the data. -```lua +```luau local create = vide.create local source = vide.source local effect = vide.effect @@ -23,7 +21,7 @@ local function Counter() instance.Text = "count: " .. count() end) - return count + return instance end ``` @@ -31,36 +29,32 @@ 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. External sources can also be passed into components for them to use. -```lua -local function Counter(props: { count: () -> number }) +```luau +local function CountDisplay(props: { count: () -> number }) local count = props.count - local instance = create "TextButton" { - Activated = function() - count(count() + 1) - end - } + local instance = create "TextLabel" {} effect(function() instance.Text = "count: " .. count() end) - return count + return instance end local count = source(0) -Counter { +CountDisplay { count = count } -count(1) -- the Counter component will update to display this count +count(1) -- the CountDisplay 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 stable scope. diff --git a/docs/tut/crash-course/8-cleanup.md b/docs/tut/crash-course/8-cleanup.md deleted file mode 100644 index 3a04463..0000000 --- a/docs/tut/crash-course/8-cleanup.md +++ /dev/null @@ -1,48 +0,0 @@ -# Cleanup - -Sometimes you may need to do some cleanup when destroying a component or after -a side-effect from a source update. Vide provides a function `cleanup()` which -is used to register a cleanup callback for the next time the reactive scope -it is called in re-runs. - -```lua -locla mount = vide.mount -local source = vide.source -local cleanup = vide.cleanup - -local function Timer() - local count = source(0) - - local con = game:GetService("RunService").Heartbeat:Connect(function(dt) - count(count() + dt) - end) - - cleanup(function() - con:Disconnect() - end) - - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = function() - return "seconds: " .. math.floor(count()) - end, - } -end - -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-implicit-effect.md b/docs/tut/crash-course/8-implicit-effect.md new file mode 100644 index 0000000..3363bf6 --- /dev/null +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -0,0 +1,83 @@ +# Implicit Effects + +Explicitly creating effects to update properties is tedious. You can +*implicitly* create an effect to update properties instead. + +::: code-group + +```luau [Implicit Effect] +local create = vide.create +local source = vide.source + +local function Counter() + local count = source(0) + + return create "TextButton" { + Activated = function() + count(count() + 1) + end, + + Text = function() + return "count: " .. count() + end + } +end +``` + +```luau [Explicit Effect] +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 instance +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. + +## 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. An effect is automatically created to unparent removed instances and +parent new instances on source update. + +```luau +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 text label "A" + +items { + create "TextLabel" { Text = "B" }, + create "TextLabel" { Text = "C" } +} + +-- this will automatically unparent text label "A", and parent labels "B" and "C" +``` diff --git a/docs/tut/crash-course/9-control-flow.md b/docs/tut/crash-course/9-control-flow.md deleted file mode 100644 index afeabfc..0000000 --- a/docs/tut/crash-course/9-control-flow.md +++ /dev/null @@ -1,157 +0,0 @@ -# Control Flow - -Eventually you will 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 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 source = vide.source -local switch = vide.switch - -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" { - switch(joined) { - [true] = LeaveButton, - [false] = JoinButton - } - } -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 "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()` 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" -} - -local function TodoList(props: { list: () -> Array }) - return create "Frame" { - create "UIListLayout" {}, - - indexes(todoList, function(todo, i) - return create "TextLabel" { - Text = function() - return i .. ": " .. todo() - end, - - LayoutOrder = i - } - end) - } -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 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. 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..694d90b --- /dev/null +++ b/docs/tut/crash-course/9-derived-source.md @@ -0,0 +1,93 @@ +# Derived Sources + +We have seen the basic way to derive a source: + +```luau +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. + +```luau +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() end) +effect(function() text() end) + +count(1) -- prints "ran" x2 +``` + +To avoid this, you can use `derive()` to derive a new source instead. This will +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. + +```luau +local source = vide.source +local effect = vide.effect +local derive = vide.derive + +local count = source(0) + +local text = derive(function() + print "ran" + return "count: " .. tostring(count()) +end) + +effect(function() text() end) +effect(function() text() end) + +count(1) -- prints "ran" x1 +``` + +Because `derive()` creates a reactive scope, it must 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. + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#111720", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" + } +}}%% + +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 avoid doing 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/init.luau b/init.luau new file mode 100644 index 0000000..a9ae8e0 --- /dev/null +++ b/init.luau @@ -0,0 +1,10 @@ +local vide = require "@self/src/lib" + +export type source = vide.source +export type Source = vide.Source +export type context = vide.context +export type Context = vide.Context +export type Instances = vide.Instances +export type Properties = vide.Properties + +return vide diff --git a/pesde.toml b/pesde.toml new file mode 100644 index 0000000..94c8ede --- /dev/null +++ b/pesde.toml @@ -0,0 +1,15 @@ +name = "centau/vide" +version = "0.4.1" +description = "A reactive Luau library for creating UI." +authors = ["centau"] +repository = "https://github.com/centau/vide" +license = "MIT" +includes = ["src/*", "README.md", "pesde.toml"] + +[target] +environment = "roblox" +build_files = ["src"] +lib = "src/init.luau" + +[indices] +default = "https://github.com/pesde-pkg/index" diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..4bc51cb --- /dev/null +++ b/rokit.toml @@ -0,0 +1,9 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + +[tools] +pesde = "pesde-pkg/pesde@0.6.2+registry.0.2.2" +wally = "upliftgames/wally@0.3.2" +rojo = "rojo-rbx/rojo@7.4.4" 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..c05fedf 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,161 +1,161 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof :: never -local Vector2 = game and Vector2 or require "test/mock".Vector2 :: never -local UDim2 = game and UDim2 or require "test/mock".UDim2 :: never +local typeof = game and typeof or require "../test/mock".typeof :: never -local flags = require(script.Parent.flags) -local throw = require(script.Parent.throw) -local bind = require(script.Parent.bind) -local _, is_action = require(script.Parent.action)() -local graph = require(script.Parent.graph) +local flags = require "./flags" +local implicit_effect = require "./implicit_effect" +local _, is_action = require "./action"() +local graph = require "./graph" type Node = graph.Node type Array = { V } +type ArrayOrV = {ArrayOrV} | V type Map = { [K]: V } --- buffer of event -> callback to connect after properties are set -local event_buffer = {} :: Map ()> +type Cache = { + -- event listeners to connect after properties are set + events: Array< + | string -- 1. event name + | () -> () -- 2. 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] + -- what to parent the instance to after running actions + parent: unknown, + + -- cache to detect duplicate property setting at same nesting depth + nested_debug: Map< + number, -- depth + Map -- set of property names + >, + + -- 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 free_cache: Cache? + +local function borrow_cache(): Cache + if free_cache then + local cache = free_cache + free_cache = nil + return cache + else + return { + events = {}, + actions = setmetatable({} :: any, { -- lazy init + __index = function(self, i) self[i] = {}; return self[i] end + }), + parent = nil, + nested_debug = setmetatable({} :: any, { + __index = function(self, i: number) self[i] = {}; return self[i] end + }), + nested_stack = {} + } end -}) - --- cache in strict mode to detect duplicate property set at same nesting level -local nested_debug_cache = {} :: Map> - -setmetatable(nested_debug_cache :: any, { - __index = function(_, i: number) - nested_debug_cache[i] = {} - 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, { - CFrame = CFrame, - Color3 = Color3, - UDim = UDim, - UDim2 = UDim2, - Vector2 = Vector2, - Vector3 = Vector3, - Rect = Rect -} do - aggregates[i] = v.new end --- processes a potentially nested table of values to assign to an instance -local function process_props(instance: Instance, properties: Map) - local strict = flags.strict +local function return_cache(cache: Cache ) + free_cache = cache +end - table.clear(nested_stack) - if strict then table.clear(nested_debug_cache) end - - local depth = 1 - - repeat - for property, value in properties do - if type(property) == "string" then - if strict then -- check for duplicate prop assignment at nesting layer - if nested_debug_cache[depth][property] then - throw(`duplicate property {property} at depth {depth}`) - end - nested_debug_cache[depth][property] = true +local function process_properties(properties: Map, instance: Instance, cache: Cache, depth: number) + for property, value in properties do + if type(property) == "string" then + if flags.strict then -- check for duplicate property assignment at nesting depth + if cache.nested_debug[depth][property] then + error(`duplicate property {property} at depth {depth}`, 0) end + cache.nested_debug[depth][property] = true + end - if type(value) == "table" then -- attempt aggregate init - local ctor = aggregates[typeof((instance :: any)[property])] - if ctor == nil then - throw(`cannot aggregate type {typeof(value)} for property {property}`) - end - (instance :: any)[property] = ctor(unpack(value :: {})) - elseif type(value) == "function" then - if typeof((instance :: any)[property]) == "RBXScriptSignal" then - event_buffer[property] = value :: () -> () -- add event to buffer - else - bind.property(instance, property, value :: () -> ()) -- bind property - end + if property == "Parent" then + cache.parent = value + continue + end + + if type(value) == "function" then + if typeof((instance :: any)[property]) == "RBXScriptSignal" then + table.insert(cache.events, property) -- add event name to buffer + table.insert(cache.events, value :: () -> ()) -- add event listener to buffer else - (instance :: any)[property] = value -- set property - end - elseif type(property) == "number" then - if type(value) == "function" then - bind.children(instance, value :: () -> Instance | Array) -- bind children - elseif type(value) == "table" then - if is_action(value) then - table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer - else - table.insert(nested_stack, value :: {}) - table.insert(nested_stack, depth + 1) -- push table to stack for later processing - end - else - (value :: Instance).Parent = instance -- parent child + implicit_effect.property(instance, property, value :: () -> ()) -- create implicit effect for property end + else + (instance :: any)[property] = value -- set property + end + elseif type(property) == "number" then + if type(value) == "function" then + implicit_effect.children(instance, value :: () -> ArrayOrV) -- bind children + elseif type(value) == "table" then + if is_action(value) then + table.insert(cache.actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + elseif flags.defer_nested_properties then + table.insert(cache.nested_stack, value :: {}) + table.insert(cache.nested_stack, depth + 1) -- push table to stack for later processing + else + process_properties(value :: Map, instance, cache, depth + 1) + end + elseif type(value) == "userdata" then + (value :: Instance).Parent = instance -- parent child end end - - -- pop next nested table off stack - depth = table.remove(nested_stack) :: number - properties = table.remove(nested_stack) :: {} - - until not properties + end 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()?") + error "attempt to call a constructor returned by create() with no properties" end - -- queue parent assignment if any for last - local parent: unknown = properties.Parent - if parent then properties.Parent = nil end + local caches = borrow_cache() + local events = caches.events + local actions = caches.actions + local nested_debug = caches.nested_debug + local nested_stack = caches.nested_stack - -- reset buffers - table.clear(event_buffer) - for _, buffer in next, action_buffers do - table.clear(buffer) + -- process all properties + local depth = 1 + repeat + process_properties(properties, instance, caches, depth) + depth = table.remove(nested_stack) :: number + properties = table.remove(nested_stack) :: {} + until not properties + + for i = 1, #events, 2 do + local event_name = events[i] + local event_listener = events[i + 1] + ;(instance :: any)[event_name]:Connect(event_listener) 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 actions do + for _, callback in queued do callback(instance) end end - -- finally set parent if any + local parent = caches.parent if parent then if type(parent) == "function" then - bind.parent(instance, parent :: () -> Instance) + implicit_effect.parent(instance, parent :: () -> Instance) else instance.Parent = parent :: Instance end end + table.clear(events) + for _, queued in actions do table.clear(queued) end + caches.parent = nil + if flags.strict then table.clear(nested_debug) end + table.clear(nested_stack) + + return_cache(caches) + return instance end diff --git a/src/batch.luau b/src/batch.luau new file mode 100644 index 0000000..e3e6d40 --- /dev/null +++ b/src/batch.luau @@ -0,0 +1,23 @@ +local flags = require "./flags" +local graph = require "./graph" + +local function batch(setter: () -> ()) + local already_batching = flags.batch + local from + + if not already_batching then + flags.batch = true + from = graph.get_update_queue_length() + end + + local ok, err: string? = xpcall(setter, debug.traceback) + + if not already_batching then + flags.batch = false + graph.flush_update_queue(from) + end + + if not ok then error(`error occured while batching updates: {err}`, 0) end +end + +return batch diff --git a/src/bind.luau b/src/bind.luau deleted file mode 100644 index c614590..0000000 --- a/src/bind.luau +++ /dev/null @@ -1,126 +0,0 @@ -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 get_owning_scope = graph.get_owning_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 = get_owning_scope() - - local node = create_node(binding, updater) - - set_owner(node, owner) - evaluate_node(node) -end - -type PropertyBinding = { - instance: Instance, - property: string, - source: () -> unknown -} - -local function update_property(p: PropertyBinding) - (p.instance :: any)[p.property] = p.source() - return p -end - -type ParentBinding = { - instance: Instance, - parent: () -> Instance -} - -local function update_parent(p: ParentBinding) - p.instance.Parent = p.parent() - return p -end - -type ChildrenBinding = { - instance: Instance, - cur_children_set: { [Instance]: true }, - new_children_set: { [Instance]: true }, - children: () -> Instance | { Instance } -} - -local function update_children(p: ChildrenBinding) - local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update - local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update - - local new_children = p.children() -- all (and only) children that should be parented after this update - - if type(new_children) ~= "table" then - new_children = { new_children } - end - - if new_children then - for _, child in next, new_children :: { Instance } do - new_child_set[child] = true -- record child set from this update - if not cur_children_set[child] then - child.Parent = p.instance -- if child wasn't already parented then parent it - else - cur_children_set[child] = nil -- remove child from cache if it was already in cache - end - end - end - - for child in next, cur_children_set do - child.Parent = nil -- unparent all children that weren't in the new children set - end - - table.clear(cur_children_set) -- clear cache, preserve capacity - p.cur_children_set, p.new_children_set = new_child_set, cur_children_set - - return p -end - -return { - property = function(instance, property, source) - return create_binding(update_property, { - instance = instance, - property = property, - source = source - }) - end, - - parent = function(instance, parent) - return create_binding(update_parent, { - instance = instance, - parent = parent - }) - end, - - children = function(instance, children) - return create_binding(update_children, { - instance = instance, - cur_children_set = {}, - new_children_set = {}, - children = children - }) - end -} diff --git a/src/branch.luau b/src/branch.luau new file mode 100644 index 0000000..2138d06 --- /dev/null +++ b/src/branch.luau @@ -0,0 +1,40 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local destroy = graph.destroy +local get_scope = graph.get_scope + +local function branch(fn: () -> T): (() -> (), T) + local current = get_scope() + if not current then + error(`cannot use branch() outside a stable or reactive scope`, 0) + end + + local parent = current.owner + if not parent then + error(`current scope is not owned by a scope`, 0) + end + + local node = create_node(parent, false, false) + + local destroy = function() + destroy(node) + end + + push_scope(node) + + local ok, result = xpcall(fn, debug.traceback) + + pop_scope() + + if not ok then + destroy() + error(`error while running branch():\n\n{result}`, 0) + end + + return destroy, result +end + +return branch diff --git a/src/changed.luau b/src/changed.luau index 5d439c6..063f7e9 100644 --- a/src/changed.luau +++ b/src/changed.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local action = require(script.Parent.action)() -local cleanup = require(script.Parent.cleanup) +local action = require "./action"() +local cleanup = require "./cleanup" local function changed(property: string, callback: (T) -> ()) return action(function(instance) @@ -12,6 +10,8 @@ local function changed(property: string, callback: (T) -> ()) cleanup(function() con:Disconnect() end) + + callback((instance :: any)[property]) end) end diff --git a/src/cleanup.luau b/src/cleanup.luau index 0bfb6c1..6c46bbf 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 graph = require "./graph" local get_scope = graph.get_scope -local add_cleanup = graph.add_cleanup +local push_cleanup = graph.push_cleanup -local function cleanup(callback: () -> ()) +local function helper(obj: any) + return + if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end + elseif type(obj) == "thread" then function() task.cancel(obj) 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 error "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" + error "cannot cleanup outside a stable or reactive scope" end; assert(scope) - add_cleanup(scope, callback) + if type(value) == "function" then + push_cleanup(scope, value :: () -> ()) + else + push_cleanup(scope, helper(value)) + end end -return cleanup +type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () } +type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } + +return cleanup :: + ( (callback: () -> ()) -> () ) & + ( (thread: thread) -> () ) & + ( (instance: Destroyable) -> () ) & + ( (connection: Disconnectable) -> () ) & + ( (instance: Instance) -> () ) & + ( (connection: RBXScriptConnection) -> () ) diff --git a/src/context.luau b/src/context.luau new file mode 100644 index 0000000..2ce3eff --- /dev/null +++ b/src/context.luau @@ -0,0 +1,74 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local get_scope = graph.get_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local set_context = graph.set_context + +export type Context = (() -> T) & ((T, () -> U) -> U) + +local nil_symbol = newproxy() +local count = 0 + +local function context(...: T): Context + count += 1 + local id = count + + local has_default = select("#", ...) > 0 + local default_value = ... + + return function(...): any -- todo: fix type error + local scope: Node? | false = get_scope() + + if select("#", ...) == 0 then -- get + while scope do + local ctx = scope.context + + if not ctx then + scope = scope.owner + continue + end + + local value = (ctx :: { unknown })[id] + + if value == nil then + scope = scope.owner + continue + end + + return (if value ~= nil_symbol then value else nil) :: T + end + + if has_default ~= nil then + return default_value + else + error("attempt to get context when no context is set and no default context is set", 0) + end + else -- set + if not scope then return error("attempt to set context outside of a vide scope", 0) end + + local value, component = ... + + local new_scope = create_node(scope, false, false) + set_context(new_scope, id, if value == nil then nil_symbol else value) + + push_scope(new_scope) + + local function efn(err: string) return debug.traceback(err, 3) end + local ok, result = xpcall(component, efn) + + pop_scope() + + if not ok then + error(`error while running context:\n\n{result}`, 0) + end + + return result + end + + return nil :: any + end +end + +return context diff --git a/src/create.luau b/src/create.luau index 0ce889e..120d17d 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,74 +1,119 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof:: never -local Instance = game and Instance or require "test/mock".Instance :: never +local typeof = game and typeof or require "../test/mock".typeof :: never +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 defaults = require "./defaults" +local apply = require "./apply" +local flags = require "./flags" -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 function create_constructor_for_class(class: string): ({ [unknown]: unknown }) -> Instance + local function constructor(properties: { [unknown]: unknown }): Instance + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then error(`invalid class name {class}`, 0) end - local default: { [string]: unknown }? = defaults[class] - if default then - for i, v in next, default do - (instance :: any)[i] = v + if flags.defaults then + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in default do + (instance :: any)[i] = v + end + end + end + + return apply(instance, properties) + end + + return constructor +end + +local constructor_cache = {} :: { [string]: ({ [unknown]: unknown }) -> Instance } + +-- todo: remove support for different overloads +local function create(class_or_instance: string|Instance, properties: { [unknown]: unknown }?): unknown + if type(class_or_instance) ~= "string" and typeof(class_or_instance) ~= "Instance" then + error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0) + end + + local constructor: ({ [unknown]: unknown }) -> Instance + if type(class_or_instance) == "string" then + constructor = constructor_cache[class_or_instance] + if not constructor then + constructor = create_constructor_for_class(class_or_instance) + constructor_cache[class_or_instance] = constructor + end + else + constructor = function(props) + local clone = assert(class_or_instance:Clone(), "attempt to clone a non-archivable instance") + return apply(clone, props) 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 - -local function clone_instance(instance: Instance) - return function(properties: { [any]: unknown }): Instance - local clone = instance:Clone() - if not clone then error("Attempt to clone a non-archivable instance", 3) end - return apply(clone, properties) - end + return if properties + then constructor(properties) + else constructor end -local function create(class_or_instance: string|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)) +export type Instances = { + Folder: Folder, + BillboardGui: BillboardGui, + CanvasGroup: CanvasGroup, + Frame: Frame, + ImageButton: ImageButton, + ImageLabel: ImageLabel, + ScreenGui: ScreenGui, + ScrollingFrame: ScrollingFrame, + SurfaceGui: SurfaceGui, + TextBox: TextBox, + TextButton: TextButton, + TextLabel: TextLabel, + UIAspectRatioConstraint: UIAspectRatioConstraint, + UICorner: UICorner, + UIGradient: UIGradient, + UIGridLayout: UIGridLayout, + UIListLayout: UIListLayout, + UISizeConstraint: UISizeConstraint, + UITextSizeConstraint: UITextSizeConstraint, + UIScale: UIScale, + UIPadding: UIPadding, + UIStroke: UIStroke, + UIFlexItem: UIFlexItem, + UIPageLayout: UIPageLayout, + UITableLayout: UITableLayout, + VideoFrame: VideoFrame, + ViewportFrame: ViewportFrame, + ProximityPrompt: ProximityPrompt, + UIDragDetector: UIDragDetector, + Camera: Camera, + WorldModel: WorldModel, +} + +export type function Properties(instance: type?) + local properties = types.newtable() + + while instance do + for i, v in instance:properties() do + local connector = v.read and v.read.tag == "table" and v.read:readproperty(types.singleton("Connect")) + if connector then + local params = connector:parameters().head + if not params then continue end + local listener = params[2] + if not listener then continue end + properties:setproperty(i, types.optional(listener)) + elseif v.write then + properties:setproperty(i, types.optional(types.unionof( + v.write, + types.newfunction({}, { head = { v.write } }) + ))) + end + end + + instance = instance:readparent() end - return nil :: never + + properties:setindexer(types.number, types.any) + + return properties end -type Props = { [any]: any } -return (create :: any) :: -( (T & Instance) -> (Props) -> T ) & -( ("Folder") -> (Props) -> Folder ) & -( ("BillboardGui") -> (Props) -> BillboardGui ) & -( ("CanvasGroup") -> (Props) -> CanvasGroup ) & -( ("Frame") -> (Props) -> Frame ) & -( ("ImageButton") -> (Props) -> ImageButton ) & -( ("ImageLabel") -> (Props) -> ImageLabel ) & -( ("ScreenGui") -> (Props) -> ScreenGui ) & -( ("ScrollingFrame") -> (Props) -> ScrollingFrame ) & -( ("SurfaceGui") -> (Props) -> SurfaceGui ) & -( ("TextBox") -> (Props) -> TextBox ) & -( ("TextButton") -> (Props) -> TextButton ) & -( ("TextLabel") -> (Props) -> TextLabel ) & -( ("UIAspectRatioConstraint") -> (Props) -> UIAspectRatioConstraint ) & -( ("UICorner") -> (Props) -> UICorner ) & -( ("UIGradient") -> (Props) -> UIGradient ) & -( ("UIGridLayout") -> (Props) -> UIGridLayout ) & -( ("UIListLayout") -> (Props) -> UIListLayout ) & -( ("UIPadding") -> (Props) -> UIPadding ) & -( ("UIPageLayout") -> (Props) -> UIPageLayout ) & -( ("UIScale") -> (Props) -> UIScale ) & -( ("UISizeConstraint") -> (Props) -> UISizeConstraint ) & -( ("UIStroke") -> (Props) -> UIStroke ) & -( ("UITableLayout") -> (Props) -> UITableLayout ) & -( ("UITextSizeConstraint") -> (Props) -> UITextSizeConstraint ) & -( ("VideoFrame") -> (Props) -> VideoFrame ) & -( ("ViewportFrame") -> (Props) -> ViewportFrame ) & -( (string) -> (Props) -> Instance ) +type Create = (Name|keyof|"") -> (Properties>) -> index + +return create :: Create diff --git a/src/defaults.luau b/src/defaults.luau index 03badd6..77040ff 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,11 +1,10 @@ -local Enum = game and Enum or require "test/mock".Enum :: never -local Color3 = game and Color3 or require "test/mock".Color3 :: never -local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never +local Enum = game and Enum or require "../test/mock".Enum :: never +local Color3 = game and Color3 or require "../test/mock".Color3 :: never return { Part = { Material = Enum.Material.SmoothPlastic, - Size = Vector3.new(1, 1, 1), + Size = vector.create(1, 1, 1), Anchored = true }, @@ -111,4 +110,4 @@ return { BorderColor3 = Color3.new(0, 0, 0), BorderSizePixel = 0 } -} +} :: { [string]: { [string]: unknown} } diff --git a/src/derive.luau b/src/derive.luau index 49094d5..b941185 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,22 +1,16 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./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 push_scope_as_child_of = graph.push_scope_as_child_of +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node local function derive(source: () -> T): () -> T - local owner = get_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_scope_as_child_of(node) return node.cache end end diff --git a/src/effect.luau b/src/effect.luau index 43b12ab..5a2cd25 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -1,17 +1,11 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" local create_node = graph.create_node -local get_owning_scope = graph.get_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 = get_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/flags.luau b/src/flags.luau index 1b9f80e..be48232 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -4,4 +4,9 @@ end local is_O2 = inline_test() ~= "inline_test" -return { strict = not is_O2 } +return { + strict = not is_O2, + defaults = true, + defer_nested_properties = true, + batch = false, +} diff --git a/src/graph.luau b/src/graph.luau index f911f7b..fd22489 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -1,9 +1,6 @@ -if not game then script = require "test/relative-string" end +local flags = require "./flags" -local throw = require(script.Parent.throw) -local flags = require(script.Parent.flags) - -export type StartNode = { +export type SourceNode = { cache: T, [number]: Node } @@ -12,73 +9,81 @@ export type Node = { cache: T, effect: ((T) -> T) | false, cleanups: { () -> () } | false, - parents: { owner: StartNode?, [number]: StartNode }, - [number]: Node + + context: { [number]: unknown } | false, + + owned: { Node } | false, + owner: Node | false, + + 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 --- 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 efn(err: string) + local trace = debug.traceback(err, 2) - check_for_yield = function(fn, ...: any) - local args = { ... } - - t.__unm = function(_) - fn(unpack(args)) - end - - local ok, err: string? = pcall(function() - local _ = -t + if string.find(err, "^effect error stacktrace") then -- if effect error is nested + trace = string.gsub(" " .. trace, "\n", function() -- indent entire error + return "\n " end) - - return ok, if err == "attempt to yield across metamethod/C-call boundary" - or err == "thread is not yieldable" then "yield occured" - else err end + + trace ..= "\nsource update stacktrace:" +return trace +end + +local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) + + 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, debug.traceback(thread, "attempt to yield in reactive scope") + end + + return run_ok, result end local function get_scope(): Node? return scopes[scopes.n] end -local function get_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 non-reactive scope, must be used within a root() or mount() callback`) + return error(`cannot use {caller_name}() outside a stable or reactive scope`, 0) elseif scope.effect then - throw("owning scope is not stable; are you trying to derive a new source from within a side-effect?") + error("cannot create a new reactive scope inside another reactive scope", 0) 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.parents.owner = owner - table.insert(owner, node) -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 @@ -86,147 +91,197 @@ 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 + for _, fn in node.cleanups do + local ok, err: string? = xpcall(fn, debug.traceback) + if not ok then error(`cleanup error: {err}`, 0) end end + table.clear(node.cleanups) end end -local function remove_child(parent: StartNode, child: Node) - local idx = table.find(parent, child) - assert(idx, "child not found") - local n = #parent - parent[idx] = parent[n] - parent[n] = nil +local function find_and_swap_pop(t: { T }, v: T) + local i = table.find(t, v) :: number + local n = #t + t[i] = t[n] + t[n] = nil end local function unparent(node: Node) local parents = node.parents - for i, parent in ipairs(parents) do - remove_child(parent, node) + 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) - unparent(node) - - node.effect = false - - if node.parents.owner then - remove_child(node.parents.owner, node) - node.parents.owner = nil + if flags.strict and table.find(scopes, node) then + error("attempt to destroy an active scope", 0) end - while node[1] do destroy(node[1]) end + flush_cleanups(node) + unparent(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 +end + +local function destroy_owned(node: Node) + if node.owned then + local owned = node.owned + while owned[1] do destroy(owned[1]) end + end 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) - open_scope(node) + if table.find(scopes, node) then + error("a scope, that should rerun due to the update of a source, is already active", 0) + end - local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) - - close_scope() - - if not ok then throw(err :: string) end - end + local initial_value = node.cache - run_cleanups(node) -- todo: move in scope? - open_scope(node) + for i = 1, 2 do + local cur_value = node.cache - local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) - - 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 -- 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 i = 1 - local child = node[i] - while child do - 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] + 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 + error(`effect error stacktrace\n{new_value :: string}`, 0) end - child = next_child + node.cache = new_value :: T end + + return initial_value ~= node.cache + else + local cur_value = node.cache + + flush_cleanups(node) + destroy_owned(node) + + push_scope(node) + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) + pop_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + error(`effect error:\n{new_value}\n`, 0) + end + + node.cache = new_value + return cur_value ~= new_value end +end - update_queue.n = n +local function queue_children_for_update(node: SourceNode) + local i = update_queue.n + while node[1] do + i += 1 + update_queue[i] = node[1] + unparent(node[1]) + end + update_queue.n = i +end - -- evaluate all queued children - for i = n0 + 1, n do - local child = update_queue[i] - if not child.effect then continue end +local function get_update_queue_length() + return update_queue.n +end - if evaluate_node(child) then - update_from(child, n) +local function flush_update_queue(from: number) + local i = from + 1 + while i <= update_queue.n do + local node = update_queue[i] + --assert(node.effect) + + if node.owner and evaluate_node(node) then + queue_children_for_update(node) + end + + update_queue[i] = false :: any + i += 1 + end + + update_queue.n = from +end + +local function update_descendants(root: SourceNode) + local n0 = update_queue.n + queue_children_for_update(root) + + if flags.batch then return end + + local i = n0 + 1 + while i <= update_queue.n do + local node = update_queue[i] + --assert(node.effect) + + -- check if node is still owned in case destroyed after queued + if node.owner and evaluate_node(node) then + queue_children_for_update(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 function push_scope_as_child_of(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, + + context = 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 @@ -234,21 +289,33 @@ local function get_children(node: Node): { Node } return { unpack(node) } :: { Node } end +local function set_context(node: Node, key: number, value: unknown) + if node.context then + node.context[key] = value + else + node.context = { [key] = value } + end +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, - get_owning_scope = get_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_scope_as_child_of = push_scope_as_child_of, + 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, - scopes = scopes + flush_update_queue = flush_update_queue, + get_update_queue_length = get_update_queue_length, + set_context = set_context, + scopes = scopes, + + q = update_queue } diff --git a/src/implicit_effect.luau b/src/implicit_effect.luau new file mode 100644 index 0000000..6fed9bc --- /dev/null +++ b/src/implicit_effect.luau @@ -0,0 +1,125 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local assert_stable_scope = graph.assert_stable_scope +local get_scope = graph.get_scope +local evaluate_node = graph.evaluate_node +local push_cleanup = graph.push_cleanup + +local function update_property_effect(p: { + instance: Instance, + property: string, + source: () -> unknown +}) + (p.instance :: any)[p.property] = p.source() + return p +end + +local function update_parent_effect(p: { + instance: Instance, + source: () -> Instance +}) + p.instance.Parent = p.source() + return p +end + +-- todo: investigate if "count" method used in indexes() and values() can improve performance here +local function update_children_effect(p: { + instance: Instance, + cur_children_set: { [Instance]: true }, + new_children_set: { [Instance]: true }, + source: () -> Instance | { Instance } +}) + local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update + local new_children_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update + + local new_children = p.source() -- all (and only) children that should be parented after this update + + local function process_child(child: Instance | { Instance }) + if type(child) == "userdata" then + if new_children_set[child] then return end -- stops redundant reparenting + + new_children_set[child] = true -- record child set from this update + + if not cur_children_set[child] then + child.Parent = p.instance -- if child wasn't already parented then parent it + else + cur_children_set[child] = nil -- remove child from cache if it was already in cache + end + elseif type(child) == "table" then + for _, child in child do + process_child(child) + end + elseif type(child) == "function" then + local node = create_node(assert(get_scope()), update_children_effect, { + instance = p.instance, + cur_children_set = {}, + new_children_set = {}, + source = child + }) + + evaluate_node(node) + + push_cleanup(assert(get_scope()), function() + for child in node.cache.cur_children_set do + child.Parent = nil + end + end) + end + end + + process_child(new_children) + + for child in cur_children_set do + child.Parent = nil -- unparent all children that weren't in the new children set + end + + table.clear(cur_children_set) -- clear cache, preserve capacity + p.cur_children_set, p.new_children_set = new_children_set, cur_children_set + + return p +end + +return { + property = function(instance, property, source) + local node = create_node(assert_stable_scope(), update_property_effect, { + instance = instance, + property = property, + source = source + }) + + evaluate_node(node) + + return node + end, + + parent = function(instance, parent) + local node = create_node(assert_stable_scope(), update_parent_effect, { + instance = instance, + source = parent + }) + + evaluate_node(node) + + return node + end, + + children = function(instance, children) + local node = create_node(assert_stable_scope(), update_children_effect, { + instance = instance, + cur_children_set = {}, + new_children_set = {}, + source = children + }) + + evaluate_node(node) + + push_cleanup(assert_stable_scope(), function() + for child in node.cache.cur_children_set do + child.Parent = nil + end + end) + + return node + end +} diff --git a/src/indexes.luau b/src/indexes.luau new file mode 100644 index 0000000..fe8416e --- /dev/null +++ b/src/indexes.luau @@ -0,0 +1,121 @@ +local flags = require "./flags" +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () + +type Array = { T } +type Map = { [K]: V } +type Source = () -> T + +local function indexes( + input: Source>, + component: (Source, K, Source) -> (Obj, ...number) +): Source> + local update_count = 0 + local scopes = {} :: Map (), + object: Obj, + value: V?, + value_source: (V?) -> V, + count: number, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }?, + }> + + local output = source({} :: Array) + local function update_output() + local objects = table.create(4) + + for _, scope in scopes do + table.insert(objects, scope.object) + end + + output(objects) + end + + effect(function() + local data = input() + + local count = update_count + update_count += 1 + + local children_need_update = false -- set to true if a scope is created or destroyed + + -- create or update scopes + for i, v in data do + local scope = scopes[i] + + if scope == nil then -- create new scope and create component + local value_source = source(v) + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(value_source, i, present) + delay = t + return object + end) + + present(true) + + children_need_update = true + + scopes[i] = { + destroy = destroy, + object = object, + value = v, + value_source = value_source, + count = count, + delay = delay or 0, + present = present, + timeout = nil, + } + else -- update scope + scope.count = count + + if scope.value ~= v then + if scope.timeout then -- index is in input table again; cancel destruction + scope.timeout.cancel = true + scope.timeout = nil + scope.present(true) + end + + scope.value = v + scope.value_source(v) + end + end + end + + -- destroy scopes + for i, scope in scopes do + if scope.count < count then -- if count is not latest then index is no longer in the input table + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[i] = nil + children_need_update = true + else + scope.value = nil -- set to nil for the `scope.value ~= v` check + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() -- todo: possible redundant updates + scope.destroy() + scopes[i] = nil + update_output() + end) + end + end + end + end + + if children_need_update then + update_output() + end + end) + + return output +end + +return indexes diff --git a/src/init.luau b/src/init.luau index 62b6281..c0fbe0e 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,112 +1,12 @@ --------------------------------------------------------------------------------- --- vide.luau --- v0.1.0 --------------------------------------------------------------------------------- +assert(game, "when using vide outside of Roblox, require lib.luau instead") -if not game then script = require "test/relative-string" end +local vide = require("@self/lib") -local root = require(script.root) -local mount = require(script.mount) -local create = require(script.create) -local apply = require(script.apply) -local source = require(script.source) -local effect = require(script.effect) -local cleanup = require(script.cleanup) -local untrack = require(script.untrack) -local derive = require(script.derive) -local switch = require(script.switch) -local show = require(script.show) -local indexes, values = require(script.maps)() -local spring, update_springs = require(script.spring)() -local action = require(script.action)() -local changed = require(script.changed) -local throw = require(script.throw) -local flags = require(script.flags) - -export type Source = source.Source - -local function step(dt: number) - if game then - debug.profilebegin("VIDE STEP") - debug.profilebegin("VIDE SPRING") - end - - update_springs(dt) - - if game then - debug.profileend() - debug.profileend() - end -end - -local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) - task.defer(step, dt) -end) - -local vide = { - -- core - root = root, - mount = mount, - create = create, - source = source, - effect = effect, - derive = derive, - switch = switch, - show = show, - indexes = indexes, - values = values, - - -- util - cleanup = cleanup, - untrack = untrack, - read = function(value: T | () -> T): T - return if type(value) == "function" then value() else value - end, - - -- animations - spring = spring, - - -- actions - action = action, - changed = changed, - - -- flags - strict = (nil :: any) :: boolean, - - -- temporary - apply = function(instance: Instance) - return function(props: { [any]: any }) - apply(instance, props) - return instance - end - end, - - -- runtime - step = function(dt: number) - if stepped then - stepped:Disconnect() - stepped = nil - end - step(dt) - 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, - - __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 -}) +export type source = vide.source +export type Source = vide.Source +export type context = vide.context +export type Context = vide.Context +export type Instances = vide.Instances +export type Properties = vide.Properties return vide diff --git a/src/lib.luau b/src/lib.luau new file mode 100644 index 0000000..a8a3885 --- /dev/null +++ b/src/lib.luau @@ -0,0 +1,122 @@ +local version = { major = 0, minor = 4, patch = 1 } + +local root = require "./root" +local branch = require "./branch" +local mount = require "./mount" +local create = require "./create" +local apply = require "./apply" +local source = require "./source" +local effect = require "./effect" +local derive = require "./derive" +local cleanup = require "./cleanup" +local untrack = require "./untrack" +local read = require "./read" +local batch = require "./batch" +local context = require "./context" +local switch = require "./switch" +local show = require "./show" +local indexes = require "./indexes" +local values = require "./values" +local spring, update_springs = require "./spring"() +local action = require "./action"() +local changed = require "./changed" +local timeout, update_timeouts = require "./timeout"() +local flags = require "./flags" + +export type Source = source.Source +export type source = Source +export type Context = context.Context +export type context = Context +export type Instances = create.Instances +export type Properties = create.Properties + +local function step(dt: number) + if game then debug.profilebegin("VIDE STEP") end + + if game then debug.profilebegin("VIDE SPRING") end + update_springs(dt) + if game then debug.profileend() end + + if game then debug.profilebegin("VIDE SCHEDULER") end + update_timeouts(dt) + if game then debug.profileend() end + + if game then debug.profileend() end +end + +local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) + task.defer(step, dt) +end) + +local vide = { + version = version, + + -- core + root = root, + --branch = branch, + mount = mount, + create = create, + source = source, + effect = effect, + derive = derive, + switch = switch, + show = show, + indexes = indexes, + values = values, + + -- util + cleanup = cleanup, + untrack = untrack, + read = read, + batch = batch, + context = context, + + -- animations + spring = spring, + + -- actions + action = action, + changed = changed, + + -- flags + strict = (nil :: any) :: boolean, + defaults = (nil :: any) :: boolean, + defer_nested_properties = (nil :: any) :: boolean, + + -- temporary + apply = function(instance: Instance) + return function(props: { [any]: any }) + apply(instance, props) + return instance + end + end, + + -- runtime + step = function(dt: number) + if stepped then + stepped:Disconnect() + stepped = nil + end + step(dt) + end +} + +setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if flags[index] == nil then + error(`{tostring(index)} is not a valid member of vide`, 0) + else + return flags[index] + end + end, + + __newindex = function(_, index: unknown, value: unknown) + if flags[index] == nil then + error(`{tostring(index)} is not a valid member of vide, 0`) + else + flags[index] = value + end + end +}) + +return vide diff --git a/src/maps.luau b/src/maps.luau deleted file mode 100644 index 1649039..0000000 --- a/src/maps.luau +++ /dev/null @@ -1,226 +0,0 @@ -if not game then script = require "test/relative-string" end - -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 -local create_node = graph.create_node -local create_start_node = graph.create_start_node -local set_owner = graph.set_owner -local track = graph.track -local update = graph.update -local get_owning_scope = graph.get_owning_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope -local evaluate_node = graph.evaluate_node -local destroy = graph.destroy - -type Map = { [K]: V } - -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 - throw("table source map cannot return primitives") - end -end - -local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = get_owning_scope() - - local subowner = create_node(false, false) - set_owner(subowner, owner) - - local input_cache = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local remove_queue = {} :: { K } - local scopes = {} :: Map> - - local function update_children(data) - -- queue removed values - for i in next, input_cache do - if data[i] == nil then - table.insert(remove_queue, i) - end - end - - -- remove queued values - for _, i in next, remove_queue do - destroy(scopes[i]) - - input_cache[i] = nil - output_cache[i] = nil - input_nodes[i] = nil - scopes[i] = nil - end - - table.clear(remove_queue) - - open_scope(subowner) - - -- process new or changed values - for i, v in next, data do - local cv = input_cache[i] - - if cv ~= v then - if cv == nil then -- create new scope and run transform - local scope = create_node(false, false) - scopes[i] = scope :: Node - - local node = create_start_node(v) - - set_owner(scope, subowner) - open_scope(scope) - - local ok, result = pcall(transform, function() - track(node) - return node.cache - end, i) - - close_scope() - - if not ok then - close_scope() -- subowner scope - error(result, 0) - end - - input_nodes[i] = node - output_cache[i] = result - else -- update source - input_nodes[i].cache = v - update(input_nodes[i]) - end - - input_cache[i] = v - end - end - - close_scope() - - local output_array = table.create(#scopes) - for _, v in next, output_cache do - table.insert(output_array, v) - end - check_primitives(output_array) - - return output_array - end - - local node = create_node(false :: any, function() - return update_children(input()) - end) - - evaluate_node(node) - - return function() - track(node) - return node.cache - end -end - -local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = get_owning_scope() - - local subowner = create_node(false, false) - set_owner(subowner, owner) - - local cur_input_cache_up = {} :: Map - local new_input_cache_up = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local scopes = {} :: Map> - - local function update_children(data: Map) - local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up - - if flags.strict then - local cache = {} - for _, v in next, data do - if cache[v] ~= nil then - throw "duplicate table value detected" - end - cache[v] = true - end - end - - open_scope(subowner) - - -- process data - for i, v in next, data do - new_input_cache[v] = i - - local cv = cur_input_cache[v] - - if cv == nil then -- create new scope and run transform - local scope = create_node(false, false) - scopes[v] = scope :: Node - - local node = create_start_node(i) - - set_owner(scope, subowner) - open_scope(scope) - - local ok, result = pcall(transform, v, function() - track(node) - return node.cache - end) - - close_scope() - - if not ok then - close_scope() -- subowner scope - error(result, 0) - end - - input_nodes[v] = node - output_cache[v] = result - else -- update source - if cv ~= i then - input_nodes[v].cache = i - update(input_nodes[v]) - end - - cur_input_cache[v] = nil - end - end - - close_scope() - - -- remove old values - for v in next, cur_input_cache do - destroy(scopes[v]) - - output_cache[v] = nil - input_nodes[v] = nil - scopes[v] = nil - end - - -- update buffer cache - table.clear(cur_input_cache) - cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache - - local output_array = table.create(#scopes) - for _, v in next, output_cache do - table.insert(output_array, v) - end - check_primitives(output_array) - - return output_array - end - - local node = create_node(false :: any, function() - return update_children(input()) - end) - - evaluate_node(node) - - return function() - track(node) - return node.cache - end -end - -return function() return indexes, values end 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/mount.luau b/src/mount.luau index 315925e..567d3d9 100644 --- a/src/mount.luau +++ b/src/mount.luau @@ -1,13 +1,10 @@ -if not game then script = require "test/relative-string" end - -local root = require(script.Parent.root) -local apply = require(script.Parent.apply) +local root = require "./root" +local apply = require "./apply" local function mount(component: () -> T, target: Instance?): () -> () - return root(function(destroy) + return root(function() local result = component() if target then apply(target, { result }) end - return destroy end) end diff --git a/src/read.luau b/src/read.luau new file mode 100644 index 0000000..3764315 --- /dev/null +++ b/src/read.luau @@ -0,0 +1,5 @@ +local function read(value: T | () -> T): T + return if type(value) == "function" then value() else value +end + +return read diff --git a/src/root.luau b/src/root.luau index 50a1c1c..bc9c2fc 100644 --- a/src/root.luau +++ b/src/root.luau @@ -1,38 +1,35 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local graph = require "./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 function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) + local node = create_node(false, false, false) refs[node] = true -- prevent gc of root node local destroy = function() - if not refs[node] then throw "root already destroyed" end + if not refs[node] then error "root already destroyed" end refs[node] = nil destroy(node) end - open_scope(node) + push_scope(node) - local result = { pcall(fn, destroy) } + local result = { xpcall(fn, debug.traceback, destroy) } - close_scope() + pop_scope() if not result[1] then - refs[node] = nil - throw(`mount error\n{result[2]}`) + destroy() + error(`error while running root():\n\n{result[2]}`, 0) end - return unpack(result :: any, 2) + return destroy, unpack(result :: any, 2) end -return root :: ((fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ()) +return root :: (fn: (destroy: () -> ()) -> T...) -> (() -> (), T...) diff --git a/src/show.luau b/src/show.luau index 1cf60ca..27b9d45 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,18 +1,36 @@ -if not game then script = require "test/relative-string" end +local source = require "./source" +local derive = require "./derive" +local effect = require "./effect" +local untrack = require "./untrack" +local switch = require "./switch" -local switch = require(script.Parent.switch) +type Array = { T } +type Source = () -> T -local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? - local function truthy() - return not not source() - end +local function show( + input: Source, + component: (Source, Source) -> (Obj, ...number), + fallback: ((Source) -> (Obj, ...number))? +): Source> + local filtered_input = source() - return switch(truthy) { - [true] = component, - [false] = fallback, + effect(function() + local v = input() + if v then + filtered_input(v) + end + end) + + local input_is_truthy = derive(function() + return not not input() + end) + + return switch(input_is_truthy) { + [true] = function(present) + return component(filtered_input, present) + end, + [false] = fallback } end -return show :: - ((source: () -> any, component: () -> T) -> () -> T?) & - ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) +return show diff --git a/src/source.luau b/src/source.luau index f1307fc..1da515a 100644 --- a/src/source.luau +++ b/src/source.luau @@ -1,19 +1,17 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./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_scope_as_child_of = graph.push_scope_as_child_of +local update_descendants = graph.update_descendants -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) + local node = create_source_node(initial_value) - return function(...): T + local function update_source(...): T if select("#", ...) == 0 then -- no args were given - track(node) + push_scope_as_child_of(node) return node.cache end @@ -23,9 +21,11 @@ local function source(initial_value: T): Source end node.cache = v - update(node) + update_descendants(node) return v end + + return update_source end return source :: ((initial_value: T) -> Source) & (() -> Source) diff --git a/src/spring.luau b/src/spring.luau index 66dc896..ef7decf 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,104 +1,84 @@ -if not game then script = require "test/relative-string" end -local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never +local graph = require "./graph" +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local create_source_node = graph.create_source_node +local assert_stable_scope = graph.assert_stable_scope +local evaluate_node = graph.evaluate_node +local update_descendants = graph.update_descendants +local push_scope_as_child_of = graph.push_scope_as_child_of + +local UPDATE_RATE = 120 +local TOLERANCE_FACTOR = 10_000 + +type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 --[[ - -Supported datatypes: -- number -- CFrame -- Color3 -- UDim -- UDim2 -- Vector2 -- Vector3 -- Rect - Unsupported datatypes: - bool - Vector2int16 - Vector3int16 - EnumItem - ]] -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) -type Node = graph.Node -type StartNode = graph.StartNode -local create_node = graph.create_node -local create_start_node = graph.create_start_node -local get_owning_scope = graph.get_owning_scope -local evaluate_node = graph.evaluate_node -local update = graph.update -local set_owner = graph.set_owner -local track = graph.track - -local UPDATE_RATE = 120 -local TOLERANCE = 0.0001 - -type Vec3 = Vector3 - -local function Vec3(x: number?, y: number?, z: number?) - return Vector3.new(x, y, z) -end - -local ZERO = Vec3(0, 0, 0) - -type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 - -type SpringData = { +type SpringState = { k: number, -- spring constant c: number, -- damping coeff - -- dimensions 1-3 - x0_123: Vec3, - x1_123: Vec3, - v_123: Vec3, - - -- dimensions 4-6 - x0_456: Vec3, - x1_456: Vec3, - v_456: Vec3, + x0_123: vector, x0_456: vector, -- initial position + x_123: vector, x_456: vector, -- current position + x1_123: vector, x1_456: vector, -- target position + v_123: vector, v_456: vector, -- current velocity source_value: T -- current value of spring input source } -type TypeToVec6 = (T) -> (Vec3, Vec3) -type Vec6ToType = (Vec3, Vec3) -> T +type SpringSettings = ({ + position: T?, + velocity: T?, + impulse: T? +}) -> () + +type TypeToVec6 = (T) -> (vector, vector) +type Vec6ToType = (vector, vector) -> T local type_to_vec6 = { number = function(v) - return Vec3(v, 0, 0), ZERO + return vector.create(v, 0, 0), vector.zero end :: TypeToVec6, CFrame = function(v) - return v.Position, Vec3(v:ToEulerAnglesXYZ()) + return v.Position, vector.create(v:ToEulerAnglesXYZ()) end :: TypeToVec6, Color3 = function(v) -- todo: hsv, oklab? - return Vec3(v.R, v.G, v.B), ZERO + return vector.create(v.R, v.G, v.B), vector.zero end :: TypeToVec6, UDim = function(v) - return Vec3(v.Scale, v.Offset, 0), ZERO + return vector.create(v.Scale, v.Offset, 0), vector.zero end :: TypeToVec6, UDim2 = function(v) - return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) + return vector.create(v.X.Scale, v.X.Offset, v.Y.Scale), vector.create(v.Y.Offset, 0, 0) end :: TypeToVec6, Vector2 = function(v) - return Vec3(v.X, v.Y, 0), ZERO + return vector.create(v.X, v.Y, 0), vector.zero end :: TypeToVec6, Vector3 = function(v) - return v, ZERO + return v, vector.zero end :: TypeToVec6, Rect = function(v) - return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) - end :: TypeToVec6 + return vector.create(v.Min.X, v.Min.Y, v.Max.X), vector.create(v.Max.Y, 0, 0) + end :: TypeToVec6, + + table = function(v) + return vector.create(v[1] or 0, v[2] or 0, v[3] or 0), vector.create(v[4] or 0, 0, 0) + end :: TypeToVec6<{ number }> } local vec6_to_type = { @@ -111,15 +91,15 @@ 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) - return UDim.new(v.X, v.Y) + return UDim.new(v.X, math.round(v.Y)) end :: Vec6ToType, UDim2 = function(a, b) - return UDim2.new(a.X, a.Y, a.Z, b.X) + return UDim2.new(a.X, math.round(a.Y), a.Z, math.round(b.X)) end :: Vec6ToType, Vector2 = function(v) @@ -132,12 +112,16 @@ local vec6_to_type = { Rect = function(a, b) return Rect.new(a.X, a.Y, a.Z, b.X) - end :: Vec6ToType + end :: Vec6ToType, + + table = function(a, b) + return { a.X, a.Y, a.Z, b.X } + end :: Vec6ToType<{ number }> } local invalid_type = { __index = function(_, t: string) - throw(`cannot spring type {t}`) + error(`cannot spring type {t}`, 0) end } @@ -146,80 +130,133 @@ 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 } = {} -setmetatable(springs, { __mode = "v" }) +local springs: { [SpringState]: SourceNode } = {} +setmetatable(springs :: any, { __mode = "v" }) -local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local owner = get_owning_scope() +local function spring(source: () -> T, period: number?, damping_ratio: number?): (() -> T, SpringSettings) + local owner = assert_stable_scope() -- https://en.wikipedia.org/wiki/Damping - local w_n = 2*math.pi / (period or 1) local z = damping_ratio or 1 - local k = w_n^2 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") + error("spring damping too high, consider reducing damping or increasing period", 0) end - local data: SpringData = { + local data: SpringState = { k = k, c = c, - x0_123 = ZERO, - x1_123 = ZERO, - v_123 = ZERO, + x0_123 = vector.zero, + x_123 = vector.zero, + x1_123 = vector.zero, + v_123 = vector.zero, - x0_456 = ZERO, - x1_456 = ZERO, - v_456 = ZERO, + x0_456 = vector.zero, + x_456 = vector.zero, + x1_456 = vector.zero, + v_456 = vector.zero, 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() data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) data.source_value = value - springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs + springs[data] = output return value end - local updater = create_node(false :: any, updater_effect) - - set_owner(updater, owner) + local updater = create_node(owner, updater_effect, false :: any) evaluate_node(updater) -- set initial position to goal - data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + data.x_123, data.x_456 = data.x1_123, data.x1_456 -- set output to goal output.cache = data.source_value - return function() - track(output) - return output.cache - end + local config = function(p) + local x = p.position + local v = p.velocity + local dv = p.impulse + + if x then + local x_123, x_456 = type_to_vec6[typeof(x)](x) + data.x_123, data.x_456 = x_123, x_456 + data.x0_123, data.x0_456 = x_123, x_456 + end + + if v then + data.v_123, data.v_456 = type_to_vec6[typeof(v)](v) + end + + if dv then + local dv_123, dv_456 = type_to_vec6[typeof(dv)](dv) + data.v_123 += dv_123 + data.v_456 += dv_456 + end + + -- schedule spring + springs[data] = output + end :: SpringSettings + + return function(...) + if select("#", ...) == 0 then -- no args were given + push_scope_as_child_of(output) + return output.cache + end + + -- set current position to value + local v = ... :: T + data.x_123, data.x_456 = type_to_vec6[typeof(v)](v) + + -- reset velocity + data.v_123 = vector.zero + data.v_456 = vector.zero + + -- schedule spring + springs[data] = output + + -- set output to value + output.cache = v + + return v + end, config +end + +-- calculates a float tolerance, based on the magnitude of the float +local function get_min_step(x: number): number + return x/TOLERANCE_FACTOR +end +local function get_min_vector_step(direction: vector): vector + return vector.create( + get_min_step(direction.x), + get_min_step(direction.y), + get_min_step(direction.z) + ) end local function step_springs(dt: number) - for data in next, springs do - local k, c, - x0_123, x1_123, u_123, - x0_456, x1_456, u_456 = - data.k, data.c, - data.x0_123, data.x1_123, data.v_123, - data.x0_456, data.x1_456, data.v_456 + for s in springs do + local k = s.k + local c = s.c + local x_123, x_456 = s.x_123, s.x_456 + local x1_123, x1_456 = s.x1_123, s.x1_456 + local u_123, u_456 = s.v_123, s.v_456 -- calculate displacement from target - local dx_123 = x0_123 - x1_123 - local dx_456 = x0_456 - x1_456 + local dx_123 = x_123 - x1_123 + local dx_456 = x_456 - x1_456 -- calculate spring force local fs_123 = dx_123*-k @@ -229,53 +266,50 @@ local function step_springs(dt: number) local ff_123 = u_123*-c local ff_456 = u_456*-c - -- calculate acceleration step - local dv_123 = (fs_123 + ff_123)*dt - local dv_456 = (fs_456 + ff_456)*dt + -- calculate acceleration + local a_123 = (fs_123 + ff_123) + local a_456 = (fs_456 + ff_456) - -- apply acceleration step - local v_123 = u_123 + dv_123 - local v_456 = u_456 + dv_456 + -- step acceleration + local v_123 = u_123 + a_123*dt + local v_456 = u_456 + a_456*dt - -- calculate new position - local x_123 = x0_123 + v_123*dt - local x_456 = x0_456 + v_456*dt + -- step velocity + local y_123 = x_123 + v_123*dt + local y_456 = x_456 + v_456*dt - data.x0_123, data.x0_456 = x_123, x_456 - data.v_123, data.v_456 = v_123, v_456 + s.x_123, s.x_456 = y_123, y_456 + s.v_123, s.v_456 = v_123, v_456 end end -local remove_queue = {} - local function update_spring_sources() - for data, output in next, springs do - local x0_123, x1_123, v_123, - x0_456, x1_456, v_456 = - data.x0_123, data.x1_123, data.v_123, - data.x0_456, data.x1_456, data.v_456 - - local dx_123, dx_456 = - x0_123 - x1_123, - x0_456 - x1_456 + for data, output in springs do + local x0_123, x0_456 = data.x0_123, data.x0_456 + local x_123, x_456 = data.x_123, data.x_456 + local x1_123, x1_456 = data.x1_123, data.x1_456 + local v_123, v_456 = data.v_123, data.v_456 - -- todo: can this false positive? - if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then - -- close enough to target, unshedule spring and set value to target - table.insert(remove_queue, data) + local tol_123 = vector.abs(get_min_vector_step(x0_123 - x1_123)) + local tol_456 = vector.abs(get_min_vector_step(x0_456 - x1_456)) + + if + -- position is at goal (within tolerance) + vector.max(vector.abs(x_123 - x1_123), tol_123) == tol_123 + and vector.max(vector.abs(x_456 - x1_456), tol_456) == tol_456 + + -- velocity is at 0 (within tolerance) + and vector.max(vector.abs(v_123/10), tol_123) == tol_123 + and vector.max(vector.abs(v_456/10), tol_456) == tol_456 + then + springs[data] = nil output.cache = data.source_value else - output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) + output.cache = vec6_to_type[typeof(data.source_value)](x_123, x_456) end - update(output) + update_descendants(output) end - - for _, data in next, remove_queue do - springs[data] = nil - end - - table.clear(remove_queue) end return function() diff --git a/src/switch.luau b/src/switch.luau index 421d583..0e74932 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -1,67 +1,113 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) -type Node = graph.Node -type StartNode = graph.StartNode -local create_node = graph.create_node -local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner -local track = graph.track -local destroy = graph.destroy -local get_owning_scope = graph.get_owning_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () +type Array = { T } type Map = { [K]: V } +type Source = () -> T +type Component = (Source) -> (T, ...number) -local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = get_owning_scope() +local function switch_map( + input: Source, + map: Map> +): Source> + local scopes = {} :: Map (), + object: Obj, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }? + }> + local output = source(nil :: nil | Obj | Array) + local function update_output() + local objects = {} + + for _, scope in scopes do + table.insert(objects, scope.object) + end + + output( + if objects[2] then objects + elseif objects[1] then objects[1] + else nil + ) + end + + effect(function() + local key: K? = input() + + -- destroy (or queue destroy) all scopes not associated with the input key + for k, scope in scopes do + if k == key then continue end + + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[k] = nil + else + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() + scope.destroy() + scopes[k] = nil + update_output() + end) + end + end + end + + -- create new scope or abort destruction of existing scope if key exists + if key ~= nil then + local scope = scopes[key] + + if scope then + scope.present(true) + + if scope.timeout then + scope.timeout.cancel = true + scope.timeout = nil + end + else + local component = map[key] + + if component ~= nil then + if type(component) ~= "function" then + error("map must map a value to a function", 0) + end + + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(present) + delay = t + return object + end) + + present(true) + + scopes[key] = { + destroy = destroy, + object = object, + delay = delay or 0, + present = present, + timeout = nil + } + end + end + end + + update_output() + end) + + return output +end + +local function switch(input: Source): (map: Map>) -> Source> return function(map) - local last_scope: Node? - local last_component: (() -> U)? - - local function update(cached): U? - local component = map[source()] - if component == last_component then return cached end - last_component = component - - if last_scope then - destroy(last_scope :: Node) - last_scope = nil - end - - if component == nil then return nil end - - if type(component) ~= "function" then - throw("map must map a value to a function") - end - - local new_scope = create_node(false, false) - last_scope = new_scope :: Node - - set_owner(new_scope, owner) - open_scope(new_scope) - - local ok, result = pcall(component) - - close_scope() - - if not ok then error(result, 0) end - - return result - end - - local node = create_node(nil :: any, update) - - set_owner(node, owner) - evaluate_node(node) - - return function() - track(node) - return node.cache - end + return switch_map(input, map) end end diff --git a/src/throw.luau b/src/throw.luau deleted file mode 100644 index d3ea687..0000000 --- a/src/throw.luau +++ /dev/null @@ -1,9 +0,0 @@ -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) -end - -return throw diff --git a/src/timeout.luau b/src/timeout.luau new file mode 100644 index 0000000..16319e3 --- /dev/null +++ b/src/timeout.luau @@ -0,0 +1,27 @@ +local queue = {} :: { + { t: number, fn: () -> (), cancel: boolean } +} + +local function timeout(t: number, fn: () -> ()) + local handle = { t = t, fn = fn, cancel = false } + table.insert(queue, handle) + return handle +end + +local function update_timeouts(dt: number) + for i = #queue, 1, -1 do + local handle = queue[i] + handle.t -= dt + + if handle.cancel or handle.t <= 0 then + queue[i] = queue[#queue] + queue[#queue] = nil + + if not handle.cancel then + handle.fn() + end + end + end +end + +return function() return timeout, update_timeouts end 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/src/untrack.luau b/src/untrack.luau index 230674d..6e15578 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,27 +1,25 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node 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 = xpcall(source, debug.traceback) - 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 :: T + else + return source() + end end -return untrack +return untrack :: ( (fn: () -> T) -> T ) & ( (fn: () -> ()) -> () ) diff --git a/src/values.luau b/src/values.luau new file mode 100644 index 0000000..33bc5e4 --- /dev/null +++ b/src/values.luau @@ -0,0 +1,131 @@ +local flags = require "./flags" +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () + +type Array = { T } +type Map = { [K]: V } +type Source = () -> T + +local function values( + input: Source>, + component: (V, Source, Source) -> (Obj, ...number) +): Source> + local update_count = 0 + local scopes = {} :: Map (), + object: Obj, + index: K?, + index_source: (K?) -> K, + count: number, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }?, + }> + + local output = source({} :: Array) + local function update_output() + local objects = table.create(4) + + for _, scope in scopes do + table.insert(objects, scope.object) + end + + output(objects) + end + + effect(function() + local data = input() + + local count = update_count + update_count += 1 + + local children_need_update = false -- set to true if a scope is created or destroyed + + if flags.strict then -- check for duplicate values + local map = {} + for _, v in data do + if map[v] then + error("table source passed to `values()` contains duplicate values", 0) + end + map[v] = true + end + end + + -- create or update scopes + for i, v in data do + local scope = scopes[v] + + if scope == nil then -- create new scope and create component + local index_source = source(i) + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(v, index_source, present) + delay = t + return object + end) + + present(true) + + children_need_update = true + + scopes[v] = { + destroy = destroy, + object = object, + index = i, + index_source = index_source, + count = count, + delay = delay or 0, + present = present, + timeout = nil, + } + else -- update scope + scope.count = count + + if scope.index ~= i then + if scope.timeout then -- value is in input table again; cancel destruction + scope.timeout.cancel = true + scope.timeout = nil + scope.present(true) + end + + scope.index = i + scope.index_source(i) + end + end + end + + -- destroy scopes + for v, scope in scopes do + if scope.count < count then -- if count is not latest then value is no longer in the input table + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[v] = nil + children_need_update = true + else + scope.index = nil -- set to nil for the `scope.index ~= i` check + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() -- todo: possible redundant updates + scope.destroy() + scopes[v] = nil + update_output() + end) + end + end + end + end + + if children_need_update then + update_output() + end + end) + + return output +end + +return values diff --git a/test/benchmark.luau b/test/benchmarks.luau similarity index 60% rename from test/benchmark.luau rename to test/benchmarks.luau index ca9c436..cbb55f9 100644 --- a/test/benchmark.luau +++ b/test/benchmarks.luau @@ -1,13 +1,17 @@ -local testkit = require("test/testkit") +local testkit = require("./testkit") local BENCH, START = testkit.benchmark() -local vide = require "src/init" +local vide = require "../../vide" local source = vide.source local derive = vide.derive +local effect = vide.effect local indexes = vide.indexes local values = vide.values +local batch = vide.batch local cleanup = vide.cleanup +local untrack = vide.untrack local create = vide.create +local context = vide.context assert(not vide.strict) @@ -16,18 +20,18 @@ 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^20 + TITLE "sources" -WRAP_BENCH("create source", function() +BENCH("create source", function() local cache = table.create(N) for i = 1, START(N) do @@ -35,7 +39,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 +47,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 +55,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 +66,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 +82,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 +92,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 +105,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 +119,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 @@ -131,8 +135,28 @@ WRAP_BENCH("update 1->1->1->1...1000 graph", function() end end) --- todo: repeat with batching -WRAP_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, 500 do + srcs[i] = source(0) + end + + derive(function() + for i = 1, 500 do + srcs[i]() + end + return false + end) + + for i = 1, START(1) do + for idx = 1, 500 do + srcs[idx](i) + end + end +end) + +ROOT_BENCH("update 1000->1 graph (batched)", function() local srcs = {} for i = 1, 1000 do srcs[i] = source(0) @@ -146,14 +170,16 @@ WRAP_BENCH("update 1000->1 graph", function() end) for i = 1, START(1) do - for idx = 1, 1000 do - srcs[idx](i) - end + batch(function() + for idx = 1, 1000 do + srcs[idx](i) + end + end) end end) --- todo: optimize, repeat with batching -WRAP_BENCH("update 1000x 1->1 common extern. graph", function() +-- todo: optimize this case +ROOT_BENCH("update 1000 1->1 common extern. graph", function() local ext = source(-1) local srcs = {} @@ -173,8 +199,8 @@ end) TITLE "property apply" -WRAP_BENCH("apply 0 properties", function() - local apply = require "src/apply" +ROOT_BENCH("apply 0 properties", function() + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -182,8 +208,8 @@ WRAP_BENCH("apply 0 properties", function() end end) -WRAP_BENCH("apply 8 properties", function() - local apply = require "src/apply" +ROOT_BENCH("apply 8 properties", function() + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -200,8 +226,8 @@ WRAP_BENCH("apply 8 properties", function() end end) -WRAP_BENCH("bind property", function() - local apply = require "src/apply" +ROOT_BENCH("bind property", function() + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -215,8 +241,8 @@ WRAP_BENCH("bind property", function() return nil end) -WRAP_BENCH("update binding", function() - local apply = require "src/apply" +ROOT_BENCH("update binding", function() + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -232,11 +258,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 +298,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 +318,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 +342,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 +366,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 +384,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 +404,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 +429,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 @@ -405,106 +449,99 @@ WRAP_BENCH("values() all remove", function() src(data) end) -N *= 1024 +TITLE "context()" -TITLE "cleanup" - -WRAP_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 +ROOT_BENCH("set context", function() + local ctx = context() for i = 1, START(N) do - callers[i](cleanup, cleaner) + ctx(i, function() end) end end) -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() - local apply = require "src/apply" - local Vector2 = require "test/mock".Vector2 - - local label = create "TextLabel" { - AnchorPoint = Vector2.new(1, 1) - } +ROOT_BENCH("get context (depth=1)", function() + local ctx = context() + local function run() for i = 1, START(N) do - apply(label, { - AnchorPoint = Vector2.new(i, i) - }) + ctx() end + end + + ctx(1, function() + run() end) +end) - WRAP_BENCH("set aggregate mock vector2", function() - local apply = require "src/apply" - local Vector2 = require "test/mock".Vector2 +local depth = 10 +ROOT_BENCH(`get context (depth={depth})`, function() - local label = create "TextLabel" { - AnchorPoint = Vector2.new(1, 1) - } + local ctx = context() + local function run() for i = 1, START(N) do - apply(label, { - AnchorPoint = { i, i } - }) + ctx() end + end + + local function nest_effect(fn) + untrack(function() + effect(fn) + return nil + end) + end + + local f = run + for i = 1, depth - 1 do + local f_inner = f + f = function() + nest_effect(f_inner) + end + end + + ctx(1, function() + f() end) -end +end) --- innacurate due to no Vector3 in vanilla Luau --- mock vector is 200x slower than native vector +TITLE "spring()" --- WRAP_BENCH("spring update", function() --- local root, source, spring = vide.root, vide.source, vide.spring +ROOT_BENCH("spring update", function() + local root, source, spring = vide.root, vide.source, vide.spring --- local src = source(0) + local src = source(0) --- root(function() --- for i = 1, N do --- spring(src) --- end + root(function() + for i = 1, N do + spring(src) + end --- START(N) + START(N) --- src(1) + src(1) --- return nil --- end) --- end) + return nil + end) +end) --- N /= 1024 +ROOT_BENCH("spring step", function() + local root, source, spring = vide.root, vide.source, vide.spring --- WRAP_BENCH("spring step", function() --- local root, source, spring = vide.root, vide.source, vide.spring + local src = source(0) --- local src = source(0) + root(function() + for i = 1, N do + spring(src) + end --- root(function() --- for i = 1, N do --- spring(src) --- end + src(1) --- src(1) + START(N) --- START(N) + vide.step(1/60) --- vide.step(1/60) - --- return nil --- end) --- end) + return nil + end) +end) return nil diff --git a/test/create-type-test.luau b/test/create-type-test.luau new file mode 100644 index 0000000..b30ffd5 --- /dev/null +++ b/test/create-type-test.luau @@ -0,0 +1,27 @@ +local vide = require "../src/" + +local count = vide.source(0) + +vide.create("TextButton") { + BackgroundTransparency = 1, + AnchorPoint = "bad value", -- should error + InvalidProperty = true, -- should error + + Text = function() + return "count: " .. count() + end, + + Size = function() -- should error + return "bad value" + end, + + MouseEnter = function(x, y) + + end, + + Activated = "bad value", -- should error + + vide.create "TextLabel" {}, + + function() end, +} diff --git a/test/mock.luau b/test/mock.luau index 1dfb9bc..a1f22a6 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -86,6 +86,9 @@ local Instance = {} :: any do local proxies = {} :: { [Data]: userdata? } setmetatable(proxies :: any, { __mode = "v" }) + -- allocate variables for the metamethods as __index and get_proxy cross refrences each other + local __index, __newindex + local function get_data(userdata: userdata): Data local function f(userdata: userdata): ProxyMT return getmetatable(userdata :: any) @@ -94,6 +97,19 @@ local Instance = {} :: any do return f(userdata).data end + local function get_proxy(data: Data): userdata + return proxies[data] or (function() + local userdata = newproxy(true) + local proxy = getmetatable(userdata) + proxy.proxy = userdata + proxy.data = data + proxy.__index = __index + proxy.__newindex = __newindex + proxies[data] = userdata + return userdata + end)() + end + local function is_instance(value: unknown): boolean local mt = getmetatable(value :: any) return mt and mt.data and mt.data.type == "Instance" @@ -101,16 +117,16 @@ local Instance = {} :: any do local methods = {} - local function __index(userdata: userdata, property: string): () + __index = function(userdata: userdata, property: string): () local data = get_data(userdata) return if methods[property] then methods[property] elseif property == "Name" then data.name - elseif property == "Parent" then data.parent + elseif property == "Parent" then (data.parent and get_proxy(data.parent)) elseif property == "Destroying" then data.destroying else data.properties[property] end - local function __newindex(userdata: userdata, property: string, value: unknown) + __newindex = function(userdata: userdata, property: string, value: unknown) local data = get_data(userdata) if property == "Name" then if type(value) ~= "string" then error("name must be a string", 2) end @@ -135,19 +151,6 @@ local Instance = {} :: any do end end - local function get_proxy(data: Data): userdata - return proxies[data] or (function() - local userdata = newproxy(true) - local proxy = getmetatable(userdata) - proxy.proxy = userdata - proxy.data = data - proxy.__index = __index - proxy.__newindex = __newindex - proxies[data] = userdata - return userdata - end)() - end - function Instance.new(class: string): Instance local data = { name = "UNNAMED", @@ -257,40 +260,6 @@ local Vector2 = { __type = "Vector2" } :: any do end end -local Vector3 = { __type = "Vector3" } :: any do - local function new(x, y, z) - return setmetatable({ X = x, Y = y, Z = z }, Vector3) - end - - function Vector3.new(x, y, z) - return new(x or 0, y or 0, z or 0) - end - - function Vector3.__add(a, b) - return new(a.X + b.X, a.Y + b.Y, a.Z + b.Z) - end - - function Vector3.__sub(a, b) - return new(a.X - b.X, a.Y - b.Y, a.Z - b.Z) - end - - function Vector3.__mul(a, b) - return new(a.X * b, a.Y * b, a.Z * b) - end - - function Vector3.__unm(v) - return new(-v.X, -v.Y, -v.Z) - end - - function Vector3.__eq(a, b) - return a.X == b.X and a.Y == b.Y - end - - function Vector3.__index(v) - return (v.X^2 + v.Y^2 + v.Z^2)^0.5 - end -end - local UDim2 = { __type = "UDim2" } :: any do function UDim2.new(sx, ox, sy, oy) return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2)) @@ -330,7 +299,6 @@ return { Instance = Instance :: typeof(Instance), Color3 = Color3 :: typeof(Color3), Vector2 = Vector2 :: typeof(Vector2), - Vector3 = Vector3 :: typeof(Vector3), UDim2 = UDim2 :: typeof(UDim2), Enum = Enum :: typeof(Enum), typeof = typeof :: typeof(typeof) diff --git a/test/relative-string.luau b/test/relative-string.luau deleted file mode 100644 index 232215c..0000000 --- a/test/relative-string.luau +++ /dev/null @@ -1,9 +0,0 @@ -local function dir(directory: string) - return setmetatable({} :: { [string]: any }, - { __index = function(_, path) return directory .. path end }) -end - -local script = dir "src/" -script.Parent = dir "src/" - -return script diff --git a/test/spring-test.luau b/test/spring-test.luau index 2e3bc9b..1385eda 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -1,25 +1,34 @@ -local vide = require "src/init" -local testkit = require("test/testkit") +local vide = require "../../vide" -local program_time = os.clock() +local function system(): (number) -> number + local MAX = 40 + local MIN = 10 -local function step(): number - local FPS = 60 - local DT = 1/FPS + local _, input, output = vide.root(function() + local input = vide.source(MAX) + local output = vide.spring(input, 1, .3) + return input, output + end) - repeat until os.clock() - program_time >= DT - program_time += DT - return DT + local T = 10 + local t = 0 + return function(dt) + t += dt + if t >= T then + t -= T + input(input() == MAX and MIN or MAX) + end + + vide.step(dt) + + return output() + end end -local function main() - local TERMINAL_HEIGHT = 73 --* REDUCE IF BAR DOES NOT FIT IN TERMINAL - local MIN_ALPHA = 0.3 - local MAX_ALPHA = 0.7 +-------------------------------------------------------------------------------- - local MIN = TERMINAL_HEIGHT * MIN_ALPHA - local MAX = TERMINAL_HEIGHT * MAX_ALPHA - local OFFSET = TERMINAL_HEIGHT - MAX +local function redraw_block(h: number) + local OFFSET = 70 local BLOCK = "█" @@ -35,37 +44,35 @@ local function main() else "▁" end - local source = vide.source - local spring = vide.spring - local effect = vide.effect - - local value = source(MAX) - local sprung = spring(value, 1, 0.3) - - effect(function() - local v = sprung() - local fv = math.floor(v) - 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 .. "\n" .. v) - end) - - local T = 3 - local elapsed = T/1.2 - repeat local dt = step() - vide.step(dt) - - elapsed += dt - while elapsed >= T do - elapsed -= T - value(value() == MAX and MIN or MAX) - end - - until false + local h_f = math.floor(h) + local reset = "\27[H\27[2J" -- ANSI clear terminal + local offset = string.rep("\n", OFFSET - h_f) + local bar = remainder_to_block(h - h_f) .. "\n" .. string.rep(BLOCK .. "\n", h_f) + --print(reset .. offset .. bar .. "\n" .. string.format("%.1f", h)) + print(reset .. offset .. bar .. "\n" .. h) end -vide.root(main) - +local program_time = os.clock() + +local function step(): number + local FPS = 30 + local DT = 1/FPS + + repeat until os.clock() - program_time >= DT + program_time += DT + return DT +end + +local function loop() + local callback = system() + + while true do + local dt = step() + local x = callback(dt) + redraw_block(x) + end +end + +loop() diff --git a/test/stacktrace-test.luau b/test/stacktrace-test.luau new file mode 100644 index 0000000..e24c314 --- /dev/null +++ b/test/stacktrace-test.luau @@ -0,0 +1,110 @@ +local vide = require "../" + +do + print "=============================================================" + + local a = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end + +do + print "=============================================================" + + local a = vide.source(1) + local b = vide.source(1) + local c = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + b(vide.untrack(b) + 1) + end) + + vide.effect(function() + b() + c(vide.untrack(c) + 1) + end) + + + vide.effect(function() + c() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end + +do + print "=============================================================" + + local a = vide.source(1) + local b = vide.source(1) + local c = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + vide.untrack(function() -- todo: this trace appearing twice + b(b() + 1) + return nil + end) + end) + + vide.effect(function() + b() + vide.batch(function() + c(vide.untrack(c) + 1) + end) + end) + + + vide.effect(function() + c() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end 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, diff --git a/test/tests.luau b/test/tests.luau index d9600ef..af6a7f4 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,12 +1,32 @@ -local testkit = require("test/testkit") +local testkit = require "./testkit" local TEST, CASE, CHECK, FINISH = testkit.test() -local mock = require "test/mock" +local mock = require "./mock" local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 -local vide = require "src/init" -local graph = require "src/graph" +local vide = require "../../vide" + local root = vide.root + local mount = vide.mount + local create = vide.create + local source = vide.source + local effect = vide.effect + local derive = vide.derive + local switch = vide.switch + local show = vide.show + local indexes = vide.indexes + local values = vide.values + local cleanup = vide.cleanup + local untrack = vide.untrack + local read = vide.read + local batch = vide.batch + local context = vide.context + local spring = vide.spring + local action = vide.action + local changed = vide.changed + local apply = vide.apply + local step = vide.step +local graph = require "../../vide/src/graph" type Node = graph.Node type Map = { [K] : V } @@ -35,27 +55,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_scope_as_child_of = graph.push_scope_as_child_of + 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 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" @@ -63,59 +83,63 @@ TEST("graph", function() local b = node() local c = node() - open_scope(c) + push_scope(c) - track(a) - track(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) - close_scope() + pop_scope() CHECK(get_children(a)[1] == c) CHECK(get_children(b)[1] == c) end do CASE "rerun linked nodes" + local root = node() local a = node() - local b = node() - local c = node() + local b = node(root) + local c = node(root) local count = 0 local function effect(x) - track(a) - track(b) + push_scope_as_child_of(a) + push_scope_as_child_of(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 do CASE "diamond graph" - local a, b, c, d = node(), node(), node(), node() + -- a -> b -> d + -- -> c + local root = node() + 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_scope_as_child_of(a); pop_scope() + push_scope(c); push_scope_as_child_of(a); pop_scope() + push_scope(d); push_scope_as_child_of(b); push_scope_as_child_of(c); pop_scope() - update(a) + update_descendants(a) CHECK(b_cnt == 1) CHECK(c_cnt == 1) @@ -123,17 +147,18 @@ TEST("graph", function() end do CASE "duplicate child on rerun" - local a, b, c = node(), node(), node() + local root = node() + local a, b, c = node(root), node(root), node(root) function c.effect(x) - track(a) - track(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) return not x end - open_scope(c); assert(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) @@ -142,13 +167,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 @@ -163,41 +188,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_scope_as_child_of(items_updated) -- should not - add_child(root, items_updated) - do open_scope(items_updated) - track(items) + do push_scope(items_updated) + push_scope_as_child_of(items) - do open_scope(root) - add_child(root, scope1) - do open_scope(scope1) + do push_scope(root) + do push_scope(scope1) clean "scope1" - bind1 = node() + bind1 = node(scope1) - add_child(scope1, bind1) - do open_scope(bind1) + do push_scope(bind1) clean "bind1" - track(selected) - close_scope() end - close_scope() end + push_scope_as_child_of(selected) + pop_scope() end + pop_scope() end - add_child(root, scope2) - do open_scope(scope2) + do push_scope(scope2) clean "scope2" - bind2 = node() - add_child(scope2, bind2) - 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_scope_as_child_of(selected) + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end -- verify graph @@ -209,34 +229,34 @@ 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 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 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) @@ -245,12 +265,12 @@ TEST("graph", function() bind1 = NIL bind2 = NIL gc() - CHECK(#get_children(root) == 2) + CHECK(#get_children(root) == 0) CHECK(#get_children(selected) == 1) 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]) @@ -277,33 +297,29 @@ 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() + 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 end) TEST("mount()", function() - local mount = vide.mount - local create = vide.create - local source = vide.source - local cleanup = vide.cleanup - local screen = create "ScreenGui" {} local text = source "foo" @@ -333,9 +349,6 @@ TEST("mount()", function() end) TEST("root()", function() - local root = vide.root - local cleanup = vide.cleanup - local count = 0 root(function(destroy) @@ -347,9 +360,6 @@ TEST("root()", function() end) TEST("source()", wrap_root(function() - local source = vide.source - local effect = vide.effect - do CASE "create source" local src = source(1) CHECK(src() == 1) @@ -414,12 +424,6 @@ TEST("source()", wrap_root(function() end)) TEST("derive()", wrap_root(function() - local source = vide.source - 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) local b = source(2) @@ -507,7 +511,7 @@ TEST("derive()", wrap_root(function() local count = 0 local a = source(0) - local destroy = vide.mount(function() + local destroy = mount(function() local _b = derive(function() cleanup(function() count += 1 @@ -524,38 +528,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` @@ -575,10 +584,6 @@ TEST("derive()", wrap_root(function() end)) TEST("effect()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local derive = vide.derive - do CASE "rerun on source change" local a = source(1) local b = source(1) @@ -631,14 +636,10 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "root cleanup" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() cleanup(function() count += 1 end) @@ -689,15 +690,43 @@ 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() - local create = vide.create - local source = vide.source - local cleanup = vide.cleanup + do CASE "create(\"ClassName\", props) syntax" + local frame = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) + CHECK(frame.BackgroundTransparency == 0.5) + CHECK(frame.Name == "Foo") + end + + do CASE "create(Instance, props) syntax" + local frame0 = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) + local frame = create(frame0, { BackgroundTransparency = 1 }) + CHECK(frame.BackgroundTransparency == 1) + CHECK(frame.Name == "Foo") + end do CASE "apply default properties" - local defaults = require("src/defaults") + local defaults = require "../src/defaults" local frame = create "Frame" {} :: Instance & { BorderSizePixel: any, BorderColor3: any } CHECK(frame.BorderSizePixel == defaults.Frame.BorderSizePixel) CHECK(frame.BorderColor3 == defaults.Frame.BorderColor3) @@ -721,33 +750,42 @@ TEST("create()", wrap_root(function() CHECK(text.Text == "test") end - do CASE "aggregate construction" - local template = create "TextLabel" { - AnchorPoint = Vector2.new(), - Position = UDim2.new() - } - - local text = create(template) { - AnchorPoint = { 1, 2 }, - Position = { 3, 4 } - } - - CHECK(text.AnchorPoint == Vector2.new(1, 2)) - CHECK(text.Position == UDim2.new(3, 4)) + do CASE "set nested parent" + local frame = create "Frame" {} + local text = create "TextLavel" { { Parent = frame } } + CHECK(frame:GetChildren()[1] == text) + CHECK(text.Parent == frame) end - do CASE "nested precedence" + do CASE "nested deferred" local text = create "TextLabel" { { + { Text = "2" }, Text = "1", - - { Text = "2" } } } CHECK(text.Text == "2") end + do CASE "nested not deferred" + vide.defer_nested_properties = false + + local t = {} + + create "TextLabel" { + { + { function() table.insert(t, 1) end } :: any, + function() table.insert(t, 2) end, + } + } + + CHECK(t[1] == 1) + CHECK(t[2] == 2) + + vide.defer_nested_properties = true + end + do CASE "independent" local frame = create "Frame" CHECK(frame {} ~= frame {}) @@ -779,6 +817,20 @@ TEST("create()", wrap_root(function() CHECK(frame:FindFirstChild "G") end + do CASE "set false as child" + create "Frame" { + false + } + + create "Frame" { + function() return false end + } + + create "Frame" { + function() return { false } end + } + end + do CASE "binding properties to source" local name = source("Hi") local text = source("Bye") @@ -801,7 +853,7 @@ TEST("create()", wrap_root(function() do CASE "binding destroy" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() local src = source(0) return create "TextLabel" { @@ -867,7 +919,7 @@ TEST("create()", wrap_root(function() end do CASE "parent bound to source" - local wref, destroy = vide.root(function(destroy) + local _, wref, destroy = root(function(destroy) local frame = create "Frame" { Name = "Parent" } local parent = source(frame :: Frame?) @@ -893,6 +945,111 @@ TEST("create()", wrap_root(function() CHECK(not wref[1]) end + do CASE "recursive create" + local set_test_to_true = 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 "nested children effect" + local a = create "Frame" { Name = "a" } + local b = create "Frame" { Name = "b" } + local c = create "Frame" { Name = "c" } + local d = create "Frame" { Name = "d" } + local e = create "Frame" { Name = "e" } + + local children = source { + a, + { b, c, { d } }, + { { e } } + } + + local obj = create "Frame" { + children + } + + CHECK(obj:FindFirstChild("a")) + CHECK(obj:FindFirstChild("b")) + CHECK(obj:FindFirstChild("c")) + CHECK(obj:FindFirstChild("d")) + CHECK(obj:FindFirstChild("e")) + + children { + b, + { c, a }, + { { d } } + } + + CHECK(obj:FindFirstChild("a")) + CHECK(obj:FindFirstChild("b")) + CHECK(obj:FindFirstChild("c")) + CHECK(obj:FindFirstChild("d")) + CHECK(not obj:FindFirstChild("e")) + end + + do CASE "nested children source effect" + local a = create "Frame" { Name = "a" } :: Instance + local b = create "Frame" { Name = "b" } :: Instance + local c = create "Frame" { Name = "c" } :: Instance + + local nested_children = source { b, c } + local children = source { a :: Instance | () -> { Instance }, nested_children } + + local parent = create "Frame" { + Name = "parent", + children + } + + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + nested_children {} + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + nested_children { b } + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + children { a } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + nested_children { b, c } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + children { a :: Instance | () -> { Instance }, nested_children } + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + nested_children { c } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + end + do CASE "garbage collection test" local wref @@ -921,27 +1078,346 @@ TEST("create()", wrap_root(function() end)) TEST("show()", wrap_root(function() - -- uses switch() internally, more extensive testing of scoping not needed - local source = vide.source - local show = vide.show + do CASE "show component" + local input = source(true) + local function one() return 1 end - local value = source("truey" :: unknown) - local function one() return 1 end - local function two() return 2 end + local output = show(input, one) - local output = show(value, one, two) + CHECK(output() == 1) + input(false) + CHECK(output() == nil) + end - CHECK(output() == 1) - value(nil) - CHECK(output() == 2) + do CASE "fallback component" + local input = source(true) + local function one() return 1 end + local function two() return 2 end + + local output = show(input, one, two) + + CHECK(output() == 1) + input(false) + CHECK(output() == 2) + end + + do CASE "updating truth to truthy does not rerun" + local input = source(1) + local count = 0 + + local function component() + count += 1 + return 1 + end + + local output = show(input, component) + + CHECK(count == 1) + CHECK(output() == 1) + input(2) + CHECK(count == 1) + end + + do CASE "updating source passed to component" + local input = source(1 :: number?) + local count = 0 + + show(input :: () -> number?, function(value: () -> number) + effect(function() + local v = value() + + count += 1 + + CHECK(v == count) + if v ~= count then error(count) end + end) + + return true + end) + + input(2) + CHECK(count == 2) + input(3) + CHECK(count == 3) + input(nil) + CHECK(count == 3) + end + + do CASE "special strict case" + type Weapon = { + id: string, + enchant: string? + } + + vide.strict = true + + local count = 0 + local branch = 0 + + local weapon = source(nil :: Weapon?) + + effect(function() + weapon() + end) + + effect(function() + weapon() + end) + + show(weapon, function(weapon: () -> Weapon) + local enchant = function() return weapon().enchant end + + show(enchant, function(enchant: () -> string) + effect(function() + local e = enchant() + count += 1 + CHECK(e ~= nil) + if branch == 1 then + CHECK(e == "fire") + elseif branch == 2 then + CHECK(e == "poison") + end + end) + + return {} + end) + + return {} + end) + + effect(function() + weapon() + end) + + effect(function() + weapon() + end) + + branch = 1 + weapon { id = "1", enchant = "fire" } + CHECK(count == 2) + + branch = 2 + weapon { id = "1", enchant = "poison" } + CHECK(count == 4) + + weapon { id = "1", enchant = nil } + CHECK(count == 4) + + branch = 1 + weapon { id = "1", enchant = "fire" } + CHECK(count == 6) + + weapon(nil) + + branch = 2 + weapon { id = "1", enchant = "poison" } + CHECK(count == 8) + + vide.strict = false + end + + do CASE "alt" -- todo: move test + local visible = source(true) + local count = 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) + 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 + + do CASE "delay (destruction)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end) + + CHECK(output() == nil) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + input(false) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5 + 0.01) + + CHECK(output() == nil) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(cleaned) + end + + do CASE "delay (reactivate before destruction)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end) + + CHECK(output() == nil) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + input(false) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + step(0.5 + 0.01) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + end + + do CASE "delay (with fallback)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local obj_fallback = {} + local present_fallback_upval + local cleaned_fallback = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end, function(present) + present_fallback_upval = present + cleanup(function() cleaned_fallback = true end) + CHECK(present() == false) + return obj_fallback, 1 + end) + + CHECK(output() == obj_fallback) + CHECK(value_upval == nil) + CHECK(present_upval == nil) + CHECK(not cleaned) + CHECK(present_fallback_upval() == true) + CHECK(not cleaned_fallback) + + input(true) + + CHECK(type(output() == "table") and table.find(output(), obj) and table.find(output(), obj_fallback)) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + CHECK(present_fallback_upval() == false) + CHECK(not cleaned_fallback) + + step(1 + 0.01) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + CHECK(present_fallback_upval() == false) + CHECK(cleaned_fallback) + end end)) TEST("switch()", wrap_root(function() - local source = vide.source - local switch = vide.switch - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "update on source change" local input = source(true) @@ -969,28 +1445,27 @@ TEST("switch()", wrap_root(function() CHECK(output() == nil) end - do CASE "same component different map" - local input = source(0) + -- do CASE "same component different map" + -- local input = source(0) - local function component() - return {} - end + -- local function component() + -- return {} + -- end - local output = switch(input) { - [1] = component, - [2] = component - } + -- local output = switch(input) { + -- [1] = component, + -- [2] = component + -- } - CHECK(output() == nil) + -- CHECK(output() == nil) - input(1) - local instance = output() - CHECK(instance) + -- input(1) + -- local instance = output() + -- CHECK(instance) - input(2) - CHECK(output() == instance) - - end + -- input(2) + -- CHECK(output() == instance) + -- end do CASE "scoped switch" local input = source(true) @@ -1027,7 +1502,7 @@ TEST("switch()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source(1) local n0 = scopes.n @@ -1044,15 +1519,29 @@ 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 + + do CASE "delay" + -- probably unneeded because show() uses switch() internally + end 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 - do CASE "use source" local input = source { 1, 2, 3 } @@ -1070,9 +1559,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 = root(function() + local output = indexes(input, function(v, i) + count[i] += 1 + return v + end) + + return output end) input { 1, 2, 4 } @@ -1132,9 +1625,12 @@ TEST("indexes()", wrap_root(function() do -- check that `input` allows gc of `output` local input = source {} - local output = indexes(input, function(v, i) - return v, i + local destroy, output = root(function() + return indexes(input, function(v, i) + return v, i + end) end) + destroy() local wref = weak { output } @@ -1166,7 +1662,7 @@ TEST("indexes()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source { 1 } @@ -1223,14 +1719,99 @@ 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 + + do CASE "delay" + local input = source {} + + local cleaned_counts = {} :: Map + + local output = indexes(input, function(v, i, present) + cleanup(function() + cleaned_counts[i] = (cleaned_counts[i] or 0) + 1 + end) + return { value = v, index = i, present = present }, 1 + end) + + local function mapped() + local map = {} + local objects = output() + if objects then + for _, object in objects do + map[object.index] = { value = object.value, present = object.present } + end + end + return map + end + + ------------------------------------------------------------------------ + + do + CHECK(mapped()[1] == nil) + end + + input { 1, 2 } + + do + CHECK(mapped()[1].value() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(mapped()[2].present()) + end + + input { 2 } + step(0.5) + + do + CHECK(mapped()[1].value() == 2) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(not mapped()[2].present()) + end + + input { 1, 2 } + step(0.5 + 0.01) + + do + CHECK(mapped()[1].value() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(mapped()[2].present()) + end + + input { 3 } + step(1 + 0.01) + + do + CHECK(mapped()[1].value() == 3) + CHECK(mapped()[1].present()) + + CHECK(not mapped()[2]) + + CHECK(cleaned_counts[1] == nil) + CHECK(cleaned_counts[2] == 1) + end + end end)) TEST("values()", wrap_root(function() - local create = vide.create - local source = vide.source - local values = vide.values - local cleanup = vide.cleanup - do CASE "use source" local input = source { 1, 2, 3 } @@ -1330,7 +1911,7 @@ TEST("values()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source { 1 } @@ -1349,14 +1930,103 @@ TEST("values()", wrap_root(function() CHECK(n0 == n1) end + + do CASE "delay" + local input = source {} + + local cleaned_counts = {} :: Map + + local output = values(input, function(v, i, present) + cleanup(function() + cleaned_counts[v] = (cleaned_counts[v] or 0) + 1 + end) + return { value = v, index = i, present = present }, 1 + end) + + local function mapped() + local map = {} + local objects = output() + if objects then + for _, object in objects do + map[object.value] = { index = object.index, present = object.present } + end + end + return map + end + + ------------------------------------------------------------------------ + + do + CHECK(mapped()[1] == nil) + end + + input { 1 } + + do + CHECK(mapped()[1].index() == 1) + CHECK(mapped()[1].present()) + end + + input { 2 } + step(0.5) + + do + CHECK(mapped()[1].index() == 1) + CHECK(not mapped()[1].present()) + + CHECK(mapped()[2].index() == 1) + CHECK(mapped()[2].present()) + end + + input { 1, 2 } + step(0.5 + 0.01) + + do + CHECK(mapped()[1].index() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].index() == 2) + CHECK(mapped()[2].present()) + end + + input { 3 } + step(1 + 0.01) + + do + CHECK(not mapped()[1]) + CHECK(not mapped()[2]) + + CHECK(mapped()[3].index() == 1) + CHECK(mapped()[3].present()) + + CHECK(cleaned_counts[1] == 1) + CHECK(cleaned_counts[2] == 1) + CHECK(cleaned_counts[3] == nil) + end + end + + do CASE "delayed destruction deferred" + local input = source {} + local output = values(input, function() + return {}, 1 + end) + + local count = 0 + effect(function() output(); count += 1 end) + + input { 1, 2, 3 } + CHECK(count == 2) + + input {} + CHECK(count == 2) + + step(1 + 0.01) + --CHECK(count == 3) + CHECK(count == 5) + end end)) TEST("spring()", wrap_root(function() - local create = vide.create - local source = vide.source - local spring = vide.spring - local effect = vide.effect - do CASE "update source (on next step)" local value = source(10) local sprung = spring(value, 1, 1) @@ -1364,7 +2034,7 @@ TEST("spring()", wrap_root(function() CHECK(sprung() == 10) value(20) CHECK(sprung() == 10) - vide.step(1/60) + step(1/60) CHECK(sprung() ~= 10) CHECK(sprung() > 10) end @@ -1430,9 +2100,9 @@ TEST("spring()", wrap_root(function() local output = spring(input) input(1) - vide.step(0.05) + step(0.05) CHECK(output() ~= input()) -- check spring is moving - vide.step(10) -- spring finished, should be internally removed from queue + step(10) -- spring finished, should be internally removed from queue CHECK(output() == input()) -- check spring is at target local count = -1 @@ -1441,25 +2111,43 @@ TEST("spring()", wrap_root(function() count += 1 end) - vide.step(1) -- attempt to cause another spring update + step(1) -- attempt to cause another spring update CHECK(count == 0) -- check no update occurs as spring is finished -- gc() -- perform full gc input(2) -- spring should be re-added to spring queue - vide.step(0) -- process spring queue + step(0) -- process spring queue CHECK(count == 1) -- check spring was rescheduled correctly end + + do CASE "spring control" + local input = source(1) + local output, control = spring(input) + + local value = input() + local count = 0 + effect(function() + value = output() + count += 1 + end) + + CHECK(count == 1) + CHECK(value == 1) + + control { impulse = 1 } + + CHECK(count == 1) + CHECK(value == 1) + + step(1/120 + 0.001) + + CHECK(count == 2) + CHECK(value > 1) + end 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" local a = source(0) local b = source(0) @@ -1513,7 +2201,7 @@ TEST("untrack()", wrap_root(function() local input = source(0) - local output, destroy = root(function(destroy) + local _, output, destroy = root(function(destroy) local output = derive(function() outer_count += 1 @@ -1560,8 +2248,6 @@ TEST("untrack()", wrap_root(function() end)) TEST("events", function() - local create = vide.create - local function Thing(props) local instance = Instance.new("Thing") instance.Signal = Signal.new() @@ -1588,9 +2274,6 @@ TEST("events", function() end) TEST("actions", function() - local create = vide.create - local action = vide.action - do CASE "run action" local ran = false @@ -1624,11 +2307,6 @@ TEST("actions", function() end) TEST("changed()", wrap_root(function() - local root = vide.root - local create = vide.create - local source = vide.source - local changed = vide.changed - do CASE "outputs" local output = source(nil) @@ -1637,13 +2315,13 @@ TEST("changed()", wrap_root(function() changed("Text", output) } - --CHECK(output() == "a") + CHECK(output() == "a") text.Text = "b" CHECK(output() == "b") end do CASE "connection disconnected" - local text, destroy = root(function(destroy) + local _, text, destroy = root(function(destroy) local output = source(nil) return create "TextLabel" { @@ -1662,11 +2340,290 @@ 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 +TEST("batch()", wrap_root(function() + do CASE "evaluation deferred" + 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 + + 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 + + do CASE "subsequent updates do not batch" + + local a = source(0) + local b = source(0) + local c = source(0) + local d_n = 0 + + effect(function() + b() + c() + d_n += 1 + end) + + effect(function() + b(a()) + c(a()) + end) + + batch(function() + a(1) + end) + + CHECK(d_n == 3) + + end + + do CASE "recursive queue flush diamond A,B,C,D" + --[[ + + a > b > d + > c > + + ]] + + local a = source(0) + + local b = source(0) + local c = source(0) + local d = source(0) + + local count = { b = 0, c = 0, d = 0 } + effect(function() + batch(function() + b(a() % 2 == 0 and 1 or 0) + c(a() * 2) + end) + count.b += 1 + count.c += 1 + end) + + effect(function() + batch(function() + d(b() + c()) + end) + 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 == 3) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(d() == 6) + end + + do CASE "recursive queue flush diamond A,B,C,D,E" + --[[ + where b and c batches d + + a > b > e + > c > d > + + ]] + + local a = source(0) + + local b = source(0) + local c = source(0) + local d = source(0) + local e = source(0) + + local count = { b = 0, c = 0, d = 0, e = 0 } + effect(function() + batch(function() + b(a() % 2 == 0 and 1 or 0) + c(a() * 2) + end) + count.b += 1 + count.c += 1 + end) + + effect(function() + batch(function() + d(c() * 2) + end) + count.d += 1 + end) + + effect(function() + batch(function() + e(b() + d()) + end) + count.e += 1 + end) + + CHECK(e() == 1) + + a(1) + + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(count.e == 3) + CHECK(e() == 4) + + a(3) + CHECK(count.b == 3) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(count.e == 4) + CHECK(e() == 12) + + end + + do CASE "recursive queue flush diamond A,B,C,D,E,F,G" + --[[ + + a > b > d > E > G + > c ^ > F + + ]] + + local a = source(0) + + local b = source(0) + local c = source(0) + local d = source(0) + + local e = source(0) + local f = source(0) + local g = source(0) + + local count = { b = 0, c = 0, d = 0, e = 0, f = 0, g = 0 } + effect(function() + batch(function() + b(a() % 2 == 0 and 1 or 0) + c(a() * 2) + end) + count.b += 1 + count.c += 1 + end) + + effect(function() + batch(function() + d(b() + c()) + end) + count.d += 1 + end) + + effect(function() + batch(function() + e(d() % 2 == 0 and 1 or 0) + f(d() * 2) + end) + count.e += 1 + count.f += 1 + end) + + effect(function() + batch(function() + g(e() + f()) + end) + count.g += 1 + end) + + a(1) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(count.e == 2) + CHECK(count.f == 2) + CHECK(count.g == 2) + CHECK(d() == 2) + CHECK(g() == 5) + + a(3) + CHECK(count.b == 3) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(count.e == 3) + CHECK(count.f == 3) + CHECK(count.g == 3) + CHECK(d() == 6) + CHECK(g() == 13) + + + end + +end)) + +TEST("read()", wrap_root(function() do CASE "read primitive" CHECK(read(1) == 1) end @@ -1676,7 +2633,7 @@ TEST("read()", wrap_root(function() CHECK(read(src) == 1) end - do CASE "track source" + do CASE "push_scope_as_child_of source" local src = source(0) local count = 0 @@ -1690,17 +2647,316 @@ TEST("read()", wrap_root(function() end end)) -vide.strict = true +TEST("context()", function() + do CASE "set context" + local ctx = context() + + root(function() + ctx(1, function() + CHECK(ctx() == 1) + + effect(function() + CHECK(ctx() == 1) + end) + end) + end) + end + + do CASE "set context outside of scope" + local ctx = context() + + local ok = pcall(function() + ctx(1, function() end) + end) + + CHECK(not ok) + end + + do CASE "get default context" + local ctx = context(1) + + CHECK(ctx() == 1) + + root(function() + local v = ctx(2, function() + CHECK(ctx() == 2) + return ctx() + end) + + CHECK(v == 2) + CHECK(ctx() == 1) + end) + end + + do CASE "context cascade" + local ctx = context(1) + local ctx2 = context() + + root(function() + ctx(2, function() + ctx2(true, function() + show(function() return true end, function() + ctx(3, function() + effect(function() + CHECK(ctx() == 3) + untrack(function() + effect(function() + CHECK(ctx() == 3) + end) + CHECK(ctx2() == true) + return {} + end) + end) + CHECK(ctx() == 3) + end) + CHECK(ctx() == 2) + return {} + end) + end) + CHECK(ctx() == 2) + end) + + CHECK(ctx() == 1) + end) + end + + do CASE "nil context" + local ctx = context(nil) + + root(function() + CHECK(ctx() == nil) + ctx(nil, function() + CHECK(ctx() == nil) + ctx(true :: any, function() + CHECK(ctx() == true) + ctx(nil, function() + effect(function() + untrack(function() + effect(function() + CHECK(ctx() == nil) + end) + return {} + end) + end) + end) + end) + end) + end) + end +end) + +TEST("nested effects cases", function() + 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) + + destroy() + + CHECK(ran == 2) + CHECK(cleaned == 2) + end + + root(App) +end) + +TEST("graph edge cases", wrap_root(function() + 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() 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() 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 == 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 == 4) + CHECK(e() == 12) + 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 + + 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 = nil, 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() vide.strict = true - local create = vide.create - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local indexes, values = vide.indexes, vide.values - do CASE "error on derived callback yield" local src = source(1) @@ -1755,16 +3011,6 @@ TEST("strict", wrap_root(function() CHECK(count == 4) end - do CASE "indexes() error if primitive" - local src = source { 1 } - - local ok = pcall(function() - indexes(src, function() return 1 end) - end) - - CHECK(not ok) - end - do CASE "values() error if duplicate" local src = source { 1, 2, 1 } @@ -1802,6 +3048,115 @@ 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 + + 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 + + do CASE "destruction of active scope" + local src = source(false) + local count = 0 + + root(function() + show(src, function() + src(false) + cleanup(function() count += 1 end) + return {} + end) + end) + + local ok = pcall(function() + src(true) + end) + + CHECK(count == 0) + CHECK(not ok) + end + + do CASE "destruction of active scope in indexes" + local src = source {} + + local count_1 = 0 + local count_2 = 0 + + root(function() + effect(function() + untrack(function() + indexes(src, function() + cleanup(function() count_1 += 1 end) + src {} + cleanup(function() count_2 += 1 end) + return {} + end) + end) + end) + end) + + local ok = pcall(function() + src { 1 } + end) + + CHECK(not ok) + end + + do CASE "destruction of active scope in values" + local src = source {} + + local count_1 = 0 + local count_2 = 0 + + root(function() + effect(function() + untrack(function() + values(src, function() + cleanup(function() count_1 += 1 end) + src {} + cleanup(function() count_2 += 1 end) + return {} + end) + end) + end) + end) + + local ok = pcall(function() + src { {} } + end) + + CHECK(not ok) + end end)) local ok = FINISH() diff --git a/todo.md b/todo.md index 5b9fc47..6f254bd 100644 --- a/todo.md +++ b/todo.md @@ -1,13 +1,4 @@ # 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 -- 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 +- improve error traces +- prevent redundant re-eval of nodes in a complex diamond graph diff --git a/wally.toml b/wally.toml index f118dc8..1584e06 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.4.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 +]