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 558b398..3e584d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,99 @@ 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. -------------------------------------------------------------------------------- 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 7c820ff..37918e8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,70 +1,83 @@ -//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", - - // 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: "Root Scopes", link: "/tut/crash-course/6-root" }, - { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, - { text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, - { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, - { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, - { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, - { text: "Actions", link: "/tut/crash-course/13-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" } - ] - }, - { - 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 b4576b8..578b10f 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -1,39 +1,4 @@ -# Element Creation API - -
- -## mount() - -Runs a function in a new reactive scope and optionally applies its result to a -target instance. - -- **Type** - - ```lua - function mount(component: () -> T, target: Instance?): () -> () - ``` - -- **Details** - - The result of the function is applied to a target in the same way - properties are using `create()`. - - The function is ran in a new reactive scope, just like - [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() @@ -41,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** @@ -69,80 +34,60 @@ Creates a new UI element, applying any given properties. - **index is number:** - **value is action:** run action - **value is table:** recurse table - - **value is functon:** create effect to update children + - **value is function:** create effect to update children - **value is instance:** set instance as child - **Example** 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) - local con - instance:GetPropertyChangedSignal(property):Connect(function() - callback(instance[property]) + local cn = instance:GetPropertyChangedSignal(property):Connect(function() + fn(instance[property]) end) - -- disconnect on reactive scope destruction to allow gc of instance + -- disconnect on scope destruction to allow gc of instance cleanup(function() - con:Disconnect() + cn:Disconnect() end) end) end @@ -150,7 +95,7 @@ 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) } ``` @@ -161,16 +106,47 @@ A wrapper for `action()` to listen for property changes. - **Type** - ```lua - function changed(property: string, callback: (...unknown) -> ()): Action + ```luau + function changed(property: string, fn: (unknown) -> ()): Action ``` - **Details** - Will run the given callback any time the property is changed, as well as - when the action is initially run. + Will run the given function immediately and whenever the property updates. - The changed connection is disconnected when the reactive scope the action is - ran in is destroyed. + 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 133bc4a..ebbc5dd 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -1,125 +1,155 @@ -# Reactivity API: Core +# Reactivity: Core -
+## Scopes + +Vide code can run in one of two scopes: STABLE or REACTIVE. + +- 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 reactive scope. Strict mode can check for this. +Yielding is not allowed in any stable or reactive scope. Strict mode will check +for this. ::: -## root() +## root() STABLE -Creates and runs a function in a new reactive scope. +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** - Returns the result of the given function. - - Creates a new root reactive scope, where creation and derivations of sources - can be tracked and properly disposed of. - - A function to destroy the root is passed into the callback, which will run - any cleanups and allow derived sources created to garbage collect. + 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. - - Sources can be created outside of reactive scopes. + 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 in a new reactive scope on source update. +Runs a function in a new reactive scope. - **Type** - ```lua - function effect(callback: () -> ()) + ```luau + function effect(fn: () -> ()) ``` - **Details** - Any time a source referenced in the callback is changed, the callback will - be reran. - - The callback is ran to initially ran on first call to find dependent sources. + 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 in a new reactive scope 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. - - The callback is ran to initially ran on first call to find dependent sources. + 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 a16e251..b8bff4d 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -1,53 +1,58 @@ -# Reactivity API: Utility +# Reactivity: Utility ## cleanup() -Runs a callback anytime a reactive scope is reran or destroyed. +Queues a callback to run when a scope is reran or destroyed. - **Type** - ```lua - function cleanup(callback: () -> ()) - function cleanup(obj: Destroyable) - function cleanup(obj: Disconnectable) + ```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 be tracked by a 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) @@ -56,21 +61,98 @@ 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 ``` ## read() -Utility used to read a value that is either a primitive or a source. Sources -read can still be tracked inside a reactive scope. +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 958672e..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,25 +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 derived sources and effects twice each time they update,it helps -ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done correctly. +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 e156bea..a329007 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,14 +1,12 @@ -{ - "type": "module", - - "scripts": { - "docs:dev": "vitepress dev", - "docs:build": "vitepress build", - "docs:preview": "vitepress preview" - }, - - "devDependencies": { - "vitepress": "^1.0.0-rc.4", - "vitepress-plugin-mermaid": "^2.0.14" - } -} +{ + "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 20872cf..0000000 --- a/docs/tut/advanced/reactive-scoping.md +++ /dev/null @@ -1,253 +0,0 @@ -# Reactive Scoping - -This is a brief document designed to give the user more insight into how Vide's -reactive system works. - -## Graph Basics - -Vide's reactivity can be represented as a graph, where each source, derived -source, and effect is a node on that graph. The term "*reactive scope*" is just -an abstraction used to refer to these nodes. Each node is a reactive scope. - -Each node stores a cached value, a side-effect function, cleanup functions, -its parents and children, and its owner and owned. - -Whenever a node is updated it will: - -1. destroy its owned nodes -2. run its cleanups -3. rerun its side-effect and update its cached value -4. if its cached value changes, update its children recursively. - -There is a difference between children nodes and owned nodes: - -- children nodes are updated when a parent is updated. -- owned nodes are destroyed when a parent is updated. -- both children and owned are destroyed when a parent is destroyed. - -Nodes created by `root()` generally have no children, and only tracks owned. -Nodes created by `derive()` generally have no owned, and only tracks children. - -## Basic Example - -```lua -root(function() - local forename = source "quan" - local surname = source "xi" - - local name = derive(function() - return forename() .. " " .. surname() - end) - - effect(function() - print("new name: " .. name()) - end) -end) -``` - -This code will produce a graph that looks like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - subgraph root - forename & surname --> name - name --> effect - end -``` - -Nodes connected by arrows represent parent and children connections. -Nodes within other nodes represent owner and owned connections. - -Any time a node is updated, Vide will traverse and update that node's children, -its children's children, etc, until all nodes descending from that node has been -updated. Traversal will stop at a node if that node's cached value does not -change after an update. - -When the side-effect for a node is being reran when a node is updated, any -other nodes read within that side-effect are set as parents of the node -currently being reran. As those nodes are read, we know that the current node -depends on them, so any time those nodes are updated, they will update dependent -nodes since they will be stored as children. - -When destroying a node, its descendents are traversed and also destroyed. -When being destroyed, a node's connections (parents and children, owner and -owned) are cleared, and any pending cleanup functions are ran. - -The purpose of `root()` (which is called internally by `mount()`) is to setup -the root node which will track any node created inside its scope, or any -cleanups registered. Without it, nodes could be garbage collected without a -chance to run pending cleanups which can cause memory leakage. - -Nodes created by `source()` can actually exist outside of root nodes, since -they do not have direct side-effects or cleanups, they do not have to be -explicitly destroyed. - -## Control-flow Graph Example - -Control flow functions in Vide are special, as they can dynamically create and -destroy new root scopes. - -It is the combination of the above which allows us to write components like so: - -```lua -local function Counter(props: { text: string }) - local count = source(0) - - local connection = stepped:Connect(function() count(count() + 1) end) - - cleanup(function() connection:Disconnect() end) - - return create "TextLabel" { - Text = function() - return props.text() .. ": " .. count() - end - } -end -``` - -Vide doesn't recognise this as a "component", that is a user abstraction. Vide -just sees this as a function that creates nodes in the reactive graph. - -```lua -root(function() - local counters = { "A", "B" } - - indexes(counters, function(name) - return Counter { text = name } - end) -end) -``` - -This code produces a graph like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#fff" - } -}}%% - -graph LR - subgraph root - counters --> indexes - - subgraph root1[subroot 1] - n1[name] --> p1[prop binding] - end - - subgraph root2[subroot 2] - n2[name] --> p2[prop binding] - end - end - - indexes .-> root1 & root2 -``` - -This shows how the `indexes()` control flow function creates and manages new -root scopes. The function creates an effect seen as `indexes` in the graph, -which manages the new roots `subroot 1` and `subroot 2`, as well as the sources -`name` for which one exists for each index value in the input table. - -When the input table changes, `indexes()` can automatically destroy and create -subroots based on the changed indexes. Destroyed nodes run any cleanups made, in -this case it is the cleanups to disconnect the counters connection. The same -applies to all other control flow functions. - -Whenever the root reactive scope is destroyed, all its children, `counters` and -`indexes` will be destroyed too, which means that `indexes` children, the -subroots, will also be destroyed. Everything is nicely cleaned up. - -## Custom Control-flow Example - -Below is a simple example of the `show()` control-flow function. - -Each time `visible` changes, `show()` will destroy the current reactive scope -and rerun its function in a new one. - -```lua -local visible = source(true) -local count = source(0) - -root(function() - show(visible, function() - return create "TextLabel" { Text = count } - end) -end) -``` - -The above code produces a graph like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1B1B1F" - } -}}%% - -graph LR - subgraph root - direction LR - show - - subgraph subroot["show() subroot"] - p1[prop binding] - end - end - - visible --> show - count --> p1 - show -.- subroot -``` - -This can be recreated without the `show()` control-flow function, with the -following code: - -```lua -local visible = source(true) -local count = source(0) - -root(function() - local output = derive(function() - visible() - - -- untrack so any source read from within this scope - -- will not cause the outer `derive()` call to rerun, - -- we only want `derive()` to rerun when `visible` changes - return untrack(function() - local label = create "TextLabel" {} - - effect(function() - label.Text = count() - end) - - return label - end) - end) -end) -``` - -Both of the above code samples will produce the same visible result. diff --git a/docs/tut/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 604f7ae..564aa2d 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -1,45 +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/). -This tutorial assumes familiarity with Luau and Roblox GUI. - ## Why Vide? -Creating UI is a slow and tedious process. The purpose of Vide is to make UI -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. -- Being completely typecheckable. -- Independence from instance lifetimes. -- Real reactivity. +- 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 { - PlayerStats(), - Inventory(), - Settings() - } -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 index 4f62233..e25d451 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,69 +2,37 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which -is used to register a cleanup callback for the next time the reactive scope -it is called in re-runs. +is used to queue a callback for the next time a reactive scope is rerun or +destroyed, or when a stable scope is destroyed. -```lua -local mount = vide.mount +```luau +local root = vide.root local source = vide.source +local effect = vide.effect local cleanup = vide.cleanup -local function Timer() - local count = source(0) +local count = source(0) - local con = game:GetService("RunService").Heartbeat:Connect(function(dt) - count(count() + dt) +local destroy = root(function() + effect(function() + local x = count() + cleanup(function() print(x) end) end) - cleanup(function() - con:Disconnect() - end) + cleanup(function() print "root destroyed" end) +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 +count(1) -- prints "0" +count(2) -- prints "1" +destroy() -- prints "2" and "root destroyed" ``` -In the above example, this allows us to disconnect the heartbeat connection -when the 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. - -This is another reason why `mount()` is used at the top level of your app, so -that any registered cleanups created by your app components can be ran when -they are destroyed. - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - -subgraph mount - direction LR - cleanup([cleanup]) ~~~ count - count --> bind["effect (text binding)"] -end -``` +::: 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/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md deleted file mode 100644 index e5430b8..0000000 --- a/docs/tut/crash-course/11-control-flow.md +++ /dev/null @@ -1,276 +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. - -## show() - -The most basic control flow function is `show()`, which is used to conditionally -show a component. - -```lua -local source = vide.source -local show = vide.show - -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Activated = function() joined(true) end - } - end - - return create "Frame" { - show(function() return not joined() end, JoinButton) - } -end -``` - -This will make a button to join if you have not joined already. - -You can also pass a third argument, a fallback to show if the condition is falsey. - -```lua -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Activated = function() joined(true) end - } - end - - local function LeaveButton() - return Button { - Activated = function() joined(false) end - } - end - - return create "Frame" { - show(joined, LeaveButton, JoinButton) - } -end -``` - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root["mount() scope"] - direction LR - joined --> show -.- subroot - - subgraph subroot["show() scope"] - direction LR - Button - end -end -``` - -`show()` will implicitly create an effect depending on `joined`, which can be -seen as `show` on the graph. This effect manages, and can create or destroy -a separate reactive scope seen as `show() scope` on the graph. The dotted line -indicates that it isn't actually connected, only indirectly managed through -code. - -## 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 "inventory" - -switch(menu) { - inventory = InventoryMenu, - shop = ShopMenu, - settings = SettingsMenu -} -``` - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root["mount() scope"] - direction LR - menu --> switch -.- subroot - - subgraph subroot["switch() scope"] - direction LR - Menu - end -end -``` - -## indexes() - -Often, you will have a table of values with each value displayed in a similar -manner. Rather than manually looping over each value to generate a corresponding -UI element, `indexes()` allows you to create elements for each table index, to -display the value at that index. - -```lua -local todoList = source { - "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 index in the given source table, the given function will be called -with: - -1. a source containing the value of the index -2. the index itself - -When the value at an index is changed, the function is not reran. Instead, the -given source for that index is updated. - -Any time the input source table is updated, the given function will be ran for -any newly added indexes, while any removed indexes (indexes now with a `nil` -value), will have its corresponding reactive scope destroyed to clean up that -element. - -`indexes()` is said to *map* each table index to a new UI element that can -update to display the current value at that index. - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root ["mount() scope"] - direction LR - todoList --> indexes -.- subroot1 & subroot2 - - subgraph subroot1 ["indexes() scope 1"] - direction LR - value1[todo] --> prop1["prop binding"] - end - - subgraph subroot2 ["indexes() scope 2"] - direction LR - value2[todo] --> prop2[prop binding] - end -end -``` - -One thing to note regarding table sources, is that when you edit a table in a -source, you must set that table again to actually update the source. - -```lua -local src = source { 1, 2 } -local data = src() -table.insert(data, 3) -- no effects will run -src(data) -- effects will run -``` - -Together, these control flow functions cover the majority of cases where you -need to dynamically create and destroy parts of your UI. - -If you need to do something that these control flow functions cannot, you can -always use `mount()` within an effect to dynamically create and destroy -components on your own terms. Just remember to use `cleanup()` to unmount when -the effect reruns. diff --git a/docs/tut/crash-course/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/13-actions.md b/docs/tut/crash-course/12-actions.md similarity index 74% rename from docs/tut/crash-course/13-actions.md rename to docs/tut/crash-course/12-actions.md index 06b431d..60e5c30 100644 --- a/docs/tut/crash-course/13-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -1,13 +1,11 @@ # Actions -Actions in Vide are special callbacks that you can pass along with properties, +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", @@ -22,13 +20,15 @@ create "TextLabel" { 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(prop: string, callback: (new) -> ()) +local function changed(property: string, callback: (new) -> ()) return action(function(instance) - local connection = instance:GetPropertyChangedSignal(prop):Connect(function() + local connection = instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) @@ -44,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-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md deleted file mode 100644 index d8c44ce..0000000 --- a/docs/tut/crash-course/12-property-nesting.md +++ /dev/null @@ -1,120 +0,0 @@ -# Nested Properties - -Often when creating components from existing components, you can find yourself -repetitively passing through properties such as size or position. - -Example below: - -```lua -function Background(props: { - Color: Color3, - AnchorPoint: UDim2, - Position: UDim2, - Size: UDim2 -}) - return create "Frame" { - Color = props.Color - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size - } -end - -function Menu(props: { - Color = props.Color - AnchorPoint: UDim2, - Position: UDim2, - Size: UDim2 -}) - return Background { - Color = props.Color, - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size - } -end -``` - -One way this can be avoided is by using *property nesting*. In Vide, passing a -table value inside `props` has special semantics. Any key with a table value is -not assigned like a property, instead the table is iterated and processed just -like the outer table is. Any properties in the nested table will be assigned -to the instance just the same. - -Below is an example of how you can use this to pass groups of similar properties -together such as position and size, while also using typechecking. - -```lua -type Layout = { - Layout = { - Position: UDim2?, - Size: UDim2?, - AnchorPoint: Vector2? - } -} - -function Background(props: Layout & { Color: Color3 }) - return create "Frame" { - Color = props.Color, - props.Layout - } -end - -function Menu(props: Layout & { Color: Color3 }) - return Background { - Color = props.Color, - Layout = props.Layout - } -end -``` - -Here we created a nested group with the key `Layout` that can accept -layout-related properties. Any name could be chosen for the key. -This allows us to write much more concise syntax that is also typecheckable. - -In another example we use a key named `Children` to pass arrays of instances to -be parented. - -```lua -type Children = { - -- also can optionally pass a source that returns an array of children too - Children = Array | () -> Array -} - -local function List(props: Children & Layout) - return create "Frame" { - props.Children, - props.Layout, - create "UIListLayout" {} - } -end - -List { - Layout = { - Position = UDim2.new() - }, - - Children = { - create "TextLabel" { Text = "1" }, - create "TextLabel" { Text = "2" } - } -} -``` - -Deeper nested properties are guaranteed to be set after shallower nested -properties, this can be used to create overridable default properties. - -```lua -local function List(props: Children & Layout) - return create "Frame" { - props.Children, - props.Layout, - - -- can be overriden by `props.Layout` - AnchorPoint = Vector2.new(0.5, 0), - Position = UDim2.fromScale(0.5, 0), - - create "UIListLayout" {} - } -end -``` diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md similarity index 91% rename from docs/tut/crash-course/14-strict-mode.md rename to docs/tut/crash-course/13-strict-mode.md index ca8465a..0de8887 100644 --- a/docs/tut/crash-course/14-strict-mode.md +++ b/docs/tut/crash-course/13-strict-mode.md @@ -11,9 +11,9 @@ want this. Strict mode will run derived sources and effects twice each time they update. This is to help ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done correctly. +cleanups made in derived sources or effects are done properly. -```lua +```luau local source = vide.source local effect = vide.effect @@ -23,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 e66f987..668a470 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -2,53 +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. - -::: warning -When creating an instance with no properties, it is important to not forget to -actually call the constructor: `create "Frame" {}` and not `create "Frame"`. -To be clear, `create "Frame"` returns a *function* which is a constructor for -that class, not an instance of that class. This would result in you attempting -to parent a function instead of an instance which is not correct. -::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index a9f38d9..0b803ca 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,13 +1,16 @@ # Components +Vide encourages separating different parts of your UI into functions called +*components*. + A component is a function that creates and returns a piece of UI. -This is a way to separate your app into small chunks that you can reuse and put +This is a way to separate your UI into small chunks that you can reuse and put together. ::: code-group -```lua [Button.luau] +```luau [Button.luau] local create = vide.create local function Button(props: { @@ -31,13 +34,12 @@ 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), @@ -56,22 +58,8 @@ local function App() } } end - -mount(App, game.StarterGui) ``` ::: -Above is a simple example of a button component being used across files. - A single parameter `props` is used to pass properties to the component. - -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. 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 f5a848a..d862156 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,11 +1,11 @@ # Sources -Sources are special objects that store a single value. They are the core of -Vide's reactivity. They are called sources because they act as sources of data. +Sources are special objects that store a single value and are the core of +Vide's reactivity. A source can be created using `source()`. -```lua +```luau local source = vide.source local count = source(0) @@ -16,17 +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. -Derived sources should be pure functions. This is where the same output is -always produced for the same input no matter how many times it is reran. - -```lua +```luau local count = source(0) local text = function() @@ -38,8 +34,5 @@ count(1) print(text()) -- "count: 1" ``` -Sources on their own aren't very special, the above can be achieved with plain -variables. The real use for sources become apparent when used in combination -with Vide's *reactive scopes*. When a source is read from within a reactive -scope, it can automatically rerun the scope that reads it when the source is -updated in the future. +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 9f0dd6b..82768dc 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,12 +1,11 @@ # Effects -Effects are functions that are ran in response to source updates. They are -called effects because they cause *side-effects* when reacting to source -updates. +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 @@ -21,14 +20,16 @@ count(1) -- "count: 1" printed ``` -The callback given to `effect()` is initially ran immediately in a -*reactive scope*. Any source read from inside a reactive scope will be tracked, -so that if any of those sources update, the effect will be reran too. +Any source read inside an effect is tracked and will rerun the effect when +that source is updated. -Effects also work with derived sources, it doesn't matter how deeply nested +The effect runs its callback once immediately to initially figure out what +sources are being read. + +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,3 +50,22 @@ count(2) If a source is updated with the same value it already had, it will not rerun effects depending on it. + +You can also read from a source within an effect without the effect tracking it. + +```luau +local source = vide.source +local effect = vide.effect +local untrack = vide.untrack + +local a = source(0) +local b = source(0) + +effect(function() + print(`a: {a()} b: {untrack(b)}`) +end) + +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-root.md b/docs/tut/crash-course/6-root.md deleted file mode 100644 index 2b2d7ad..0000000 --- a/docs/tut/crash-course/6-root.md +++ /dev/null @@ -1,68 +0,0 @@ -# Root Reactive Scopes - -Any reactive scopes created, such as by `effect()`, must be done so within a -"root" reactive scope. This is the main purpose of `mount()`, which you use -once at the top level to create your app as shown in the first introduction. - -This is so that when the app is unmounted, it can clean up any reactive scopes -created within it, since reactive scopes track any reactive scopes created -within them. - -```lua -local source = vide.source -local effect = vide.effect - -local function App() - local count = source(0) - - effect(function() - print(count()) - end) -end - -vide.mount(App) -- works! - -App() -- will error since effect() was not called within a reactive scope -``` - -Mounting returns a function that when called will destroy any reactive scopes -created during the `mount()` call. - -```lua -local unmount = mount(App) - -unmount() -``` - -Vide's reactivity can be represented graphically, as a *reactive graph*. - -The reactive graph for the above example looks like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - -subgraph root["mount"] - direction LR - count --> effect -end -``` - -When the `mount` scope is destroyed, the `effect` scope will also be destroyed -since it was created within it. - -You don't need to worry about ensuring all your effects are created within a -root scope, since you should be creating all your UI and corresponding effects -within a top-level `mount()` call that puts all your UI together. So it is safe -to assume that any effect you create will be created under this top level scope. diff --git a/docs/tut/crash-course/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-stateful-component.md b/docs/tut/crash-course/7-reactive-component.md similarity index 61% rename from docs/tut/crash-course/7-stateful-component.md rename to docs/tut/crash-course/7-reactive-component.md index 8de8209..339ee4f 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-reactive-component.md @@ -1,13 +1,9 @@ -# Stateful Components +# 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. -## Internal State - -```lua +```luau local create = vide.create local source = vide.source local effect = vide.effect @@ -32,25 +28,16 @@ end Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. -Making a property update based on a source is also referred to as *property -binding*. - Each instance of `Counter()` will maintain its own independent count, since the -count source is created inside the scope of the component. - -## External State +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() @@ -61,13 +48,13 @@ 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 using it is created -within a reactive scope so that it can be cleaned up later. +within a stable scope. 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/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md deleted file mode 100644 index 8304565..0000000 --- a/docs/tut/crash-course/8-property-binding.md +++ /dev/null @@ -1,67 +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. - -```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 - } -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 sources map to UI, and -Vide's reactive system will automatically update any properties depending on -those sources that were updated. - -## Children Binding - -Children can also be set in a similar manner. A source passed as a child (passed -with a number key instead of string key) can return an instance or an array of -instances. Vide will automatically unparent removed instances and parent new -instances when that source's stored instances change. - -```lua -local items = source { - 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/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 17e3b77..694d90b 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -2,7 +2,7 @@ We have seen the basic way to derive a source: -```lua +```luau local count = source(0) local text = function() @@ -18,7 +18,7 @@ However, in some cases where this source could be used by multiple effects at the same time, the function wrapping the source will needlessly rerun to convert the count into a string for each effect using it. -```lua +```luau local source = vide.source local effect = vide.effect @@ -29,24 +29,20 @@ local text = function() return "count: " .. tostring(count()) end -effect(function() - text() -- prints "ran" -end) +effect(function() text() end) +effect(function() text() end) -effect(function() - text() -- prints "ran" again -end) +count(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will -run a callback in a new reactive scope only when a dependent source has updated. -Reading this derived source multiple times will just return a cached result from -when it last updated. +run a function in a reactive scope only when a source used inside updated. +Reading this derived source multiple times will just return a cached result. -```lua +```luau local source = vide.source -local derive = vide.derive local effect = vide.effect +local derive = vide.derive local count = source(0) @@ -55,16 +51,14 @@ local text = derive(function() return "count: " .. tostring(count()) end) -effect(function() - text() -- prints "ran" -end) +effect(function() text() end) +effect(function() text() end) -effect(function() - text() -- does not print, returns cached value -end) +count(1) -- prints "ran" x1 ``` -`derive()` must also be used within a root reactive scope, just like `effect()`. +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. @@ -75,12 +69,12 @@ The reactive graph for the above example: %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" } }}%% @@ -93,6 +87,7 @@ end ``` Deriving a source in this manner is similar to creating an effect to update -another source. You should never manually do this using an effect however, -improper usage could accidently create infinite loops in the reactive graph. -Always favour deriving when you need one source to update based on another. +another source. You should 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 519a554..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) diff --git a/src/cleanup.luau b/src/cleanup.luau index 803be03..6c46bbf 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,33 +1,32 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof :: never +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 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 throw("cannot cleanup given object") + 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) if type(value) == "function" then - add_cleanup(scope, value :: () -> ()) + push_cleanup(scope, value :: () -> ()) else - add_cleanup(scope, helper(value)) + push_cleanup(scope, helper(value)) end end @@ -36,6 +35,7 @@ type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } return cleanup :: ( (callback: () -> ()) -> () ) & + ( (thread: thread) -> () ) & ( (instance: Destroyable) -> () ) & ( (connection: Disconnectable) -> () ) & ( (instance: Instance) -> () ) & 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 bb42fbb..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 } @@ -13,24 +10,40 @@ export type Node = { effect: ((T) -> T) | false, cleanups: { () -> () } | false, + context: { [number]: unknown } | false, + owned: { Node } | false, owner: Node | false, - parents: { StartNode }, + parents: { SourceNode }, [number]: Node -- children } --- reactive scope stack -local scopes = { n = 0 } :: { [number]: Node, n: number } +local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack + +local function efn(err: string) + local trace = debug.traceback(err, 2) + + 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) + end + + trace ..= "\nsource update stacktrace:" +return trace +end local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) - local thread = coroutine.create(pcall) - local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) + + local thread = coroutine.create(xpcall) + --local function efn(err: string) return debug.traceback(err, 3) end + local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) assert(resume_ok) if coroutine.status(thread) ~= "dead" then - return false, "attempt to yield in reactive scope" + return false, debug.traceback(thread, "attempt to yield in reactive scope") end return run_ok, result @@ -40,44 +53,37 @@ 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("reactive scope is not an owning scope; new effects cannot be created in side-effects") + 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.owner = owner - if owner.owned then - table.insert(owner.owned, node) - else - owner.owned = { node } - end -end - -local function open_scope(node: Node) +local function push_scope(node: Node) local n = scopes.n + 1 scopes.n = n scopes[n] = node end -local function close_scope() +local function pop_scope() local n = scopes.n scopes.n = n - 1 scopes[n] = nil end -local function add_cleanup(node: Node, cleanup: () -> ()) +local function push_cleanup(node: Node, cleanup: () -> ()) if node.cleanups then table.insert(node.cleanups, cleanup) else @@ -85,161 +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 find_and_swap_pop(t: { T }, v: T) - local idx = table.find(t, v) - assert(idx, "value not found") + local i = table.find(t, v) :: number local n = #t - t[idx] = t[n] + t[i] = t[n] t[n] = nil end -local function remove_child(parent: StartNode, child: Node) - find_and_swap_pop(parent, child) -end - -local function remove_owner(node: Node) - local owner = node.owner :: Node - if node.owner and owner.owned then - find_and_swap_pop(owner.owned, node) - end -end - local function unparent(node: Node) local parents = node.parents - for i, parent in next, 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) + if flags.strict and table.find(scopes, node) then + error("attempt to destroy an active scope", 0) + end + + flush_cleanups(node) unparent(node) - remove_owner(node) + + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) + node.owner = false + end if node.owned then local owned = node.owned while owned[1] do destroy(owned[1]) end end - while node[1] do destroy(node[1]) end end local function destroy_owned(node: Node) if node.owned then - while node.owned[1] do destroy(node.owned[1]) end + local owned = node.owned + while owned[1] do destroy(owned[1]) end end end 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) + 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 initial_value = node.cache + + for i = 1, 2 do + local cur_value = node.cache + + flush_cleanups(node) + destroy_owned(node) + + push_scope(node) + local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) + pop_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + error(`effect error stacktrace\n{new_value :: string}`, 0) + end + + node.cache = new_value :: T + end + + return initial_value ~= node.cache + else + local cur_value = node.cache + + flush_cleanups(node) destroy_owned(node) - open_scope(node) + push_scope(node) + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) + pop_scope() - local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) - - close_scope() - - if not ok then throw(new_value :: string) end - - node.cache = new_value :: T + if not ok then + table.clear(update_queue) + update_queue.n = 0 + error(`effect error:\n{new_value}\n`, 0) + end + + node.cache = new_value + return cur_value ~= new_value end - - run_cleanups(node) - destroy_owned(node) - - open_scope(node) - - local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) - - close_scope() - - if not ok then - table.clear(update_queue) - update_queue.n = 0 - throw(`side-effect error from source update\n{new_value}`) - end - - node.cache = new_value - - return cur_value ~= new_value -- node has changed value end -local function update_from(node: StartNode, n0: number) - if not node[1] then return end - - local n = n0 - - -- unparent all children and queue for eval - do - local child = node[1] - while child do - --assert(child.parents.owner) - unparent(child) - n += 1 - update_queue[n] = child - child = node[1] - end +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 - update_queue.n = n +local function get_update_queue_length() + return update_queue.n +end - -- evaluate all queued children - for i = n0 + 1, n do - local child = update_queue[i] - assert(type(child.effect) == "function") +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 evaluate_node(child) then - update_from(child, n) + 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 type(scope.effect) == "function" then -- do not track nodes with no effect - add_child(node, scope) + if scope and scope.effect then -- do not track nodes with no effect + push_child(node, scope) end end -local function create_node(value: T, effect: false | (T) -> T): Node - return { +local function create_node(owner: false | Node, effect: false | (T) -> T, value: T): Node + local node: Node = { cache = value, effect = effect, cleanups = false, - owner = false, + 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 @@ -247,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 687f417..c0fbe0e 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,112 +1,12 @@ --------------------------------------------------------------------------------- --- vide.luau --- v0.1.1 --------------------------------------------------------------------------------- +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 1034e9a..0000000 --- a/src/maps.luau +++ /dev/null @@ -1,228 +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) - set_owner(node, owner) - - 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) - set_owner(node, owner) - - 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 1ddc8e6..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 :: U?, 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 86cdb7b..6e15578 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,6 +1,4 @@ -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 get_scope = graph.get_scope @@ -12,16 +10,16 @@ local function untrack(source: () -> T): T local effect = scope.effect scope.effect = false - local ok, result = pcall(source) + local ok, result = xpcall(source, debug.traceback) scope.effect = effect :: () -> () 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 74% rename from test/benchmark.luau rename to test/benchmarks.luau index f3c39b6..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) @@ -23,8 +27,7 @@ local function ROOT_BENCH(name: string, fn: () -> ()) end)() end -local N = 2^18 -- 262144 - +local N = 2^20 TITLE "sources" @@ -132,8 +135,28 @@ ROOT_BENCH("update 1->1->1->1...1000 graph", function() end end) --- todo: repeat with batching -ROOT_BENCH("update 1000->1 graph", function() +-- todo: why does it hang at 1k? it didn't before +ROOT_BENCH("update 500->1 graph", function() + local srcs = {} + for i = 1, 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) @@ -147,14 +170,16 @@ ROOT_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 -ROOT_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 = {} @@ -175,7 +200,7 @@ end) TITLE "property apply" ROOT_BENCH("apply 0 properties", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -184,7 +209,7 @@ ROOT_BENCH("apply 0 properties", function() end) ROOT_BENCH("apply 8 properties", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -202,7 +227,7 @@ ROOT_BENCH("apply 8 properties", function() end) ROOT_BENCH("bind property", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -217,7 +242,7 @@ ROOT_BENCH("bind property", function() end) ROOT_BENCH("update binding", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -424,106 +449,99 @@ ROOT_BENCH("values() all remove", function() src(data) end) -N *= 1024 +TITLE "context()" -TITLE "cleanup" - -ROOT_BENCH("register new cleanup", function() - local cleanup = cleanup - - local cleaner = function() end - - local callers = {} - - for i = 1, N do - callers[i] = function(fn, v) - fn(v) - return i -- return unique upvalue to ensure unique closure - end - end +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 - ROOT_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) - ROOT_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()" --- ROOT_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 --- ROOT_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/tests.luau b/test/tests.luau index a82712b..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,28 +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 set_owner = graph.set_owner + local push_scope = graph.push_scope + local pop_scope = graph.pop_scope local get_children = graph.get_children - local add_cleanup = graph.add_cleanup + local push_cleanup = graph.push_cleanup local destroy = graph.destroy - local function node(v: T?) - return create_node(v or false, function(x) return not x end) + local function node(owner: Node?, v: T?) + return create_node(owner or false, function(x) return not x end, v or false :: any) end - local function scope() - return create_node(false, false) + local function scope(owner: Node?) + return create_node(owner or false, false, false) end local function cleanup(fn: () -> ()) local node = assert(get_scope()) - add_cleanup(node, fn) + push_cleanup(node, fn) end do CASE "link nodes" @@ -64,12 +83,12 @@ 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) @@ -78,33 +97,30 @@ TEST("graph", function() do CASE "rerun linked nodes" local root = node() local a = node() - local b = node() - local c = node() - - set_owner(b, root) - set_owner(c, root) + local b = node(root) + local c = node(root) local count = 0 local function effect(x) - track(a) - track(b) + push_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 @@ -112,76 +128,37 @@ TEST("graph", function() -- a -> b -> d -- -> c local root = node() - local a, b, c, d = node(), node(), node(), node() - - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) + local a, b, c, d = node(), node(root), node(root), node(root) local b_cnt, c_cnt, d_cnt = 0, 0, 0 function b.effect(x) b_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end function d.effect(x) d_cnt += 1; return not x end - open_scope(b); track(a); close_scope() - open_scope(c); track(a); close_scope() - open_scope(d); track(b); track(c); close_scope() + push_scope(b); push_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) CHECK(d_cnt == 1) end - do CASE "diamond graph 2" - -- todo: include cached value from parent nodes to confirm update order - -- a -> b -> c -> e - -- -> d - local root = node() - local a, b, c, d, e = node(), node(), node(), node(), node() - - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) - set_owner(e, root) - - local b_cnt, c_cnt, d_cnt, e_cnt = 0, 0, 0, 0 - function b.effect(x) b_cnt += 1; return not x end - function c.effect(x) c_cnt += 1; return not x end - function d.effect(x) d_cnt += 1; return not x end - function e.effect(x) e_cnt += 1; return not x end - - open_scope(b); track(a); close_scope() - open_scope(c); track(b); close_scope() - open_scope(d); track(a); close_scope() - open_scope(e); track(c); track(d); close_scope() - - update(a) - - CHECK(b_cnt == 1) - CHECK(c_cnt == 1) - CHECK(d_cnt == 1) - CHECK(e_cnt == 1) - end - do CASE "duplicate child on rerun" local root = node() - local a, b, c = node(), node(), node() - - set_owner(a, root) - set_owner(b, root) - set_owner(c, root) + 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(type(c.effect) == "function" and c.effect)(NIL); close_scope() + push_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); pop_scope() - update(a) + update_descendants(a) CHECK(#get_children(a) == 1) CHECK(#get_children(b) == 1) @@ -190,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 @@ -211,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 - set_owner(items_updated, root) - do open_scope(items_updated) - track(items) + do push_scope(items_updated) + push_scope_as_child_of(items) - do open_scope(root) - set_owner(scope1, root) - do open_scope(scope1) + do push_scope(root) + do push_scope(scope1) clean "scope1" - bind1 = node() + bind1 = node(scope1) - set_owner(bind1, scope1) - do open_scope(bind1) + do push_scope(bind1) clean "bind1" - track(selected) - close_scope() end - close_scope() end + push_scope_as_child_of(selected) + pop_scope() end + pop_scope() end - set_owner(scope2, root) - do open_scope(scope2) + do push_scope(scope2) clean "scope2" - bind2 = node() - set_owner(bind2, scope2) - do open_scope(bind2) + bind2 = node(scope2) + do push_scope(bind2) clean "bind2" - track(selected) - close_scope() end - close_scope() end - close_scope() end - close_scope() end - close_scope() end + push_scope_as_child_of(selected) + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end -- verify graph @@ -266,8 +238,8 @@ TEST("graph", function() do local c = get_children(selected) CHECK(#c == 2) - CHECK(table.find(c, bind1)) - CHECK(table.find(c, bind2)) + CHECK(table.find(c, bind1 :: any)) + CHECK(table.find(c, bind2 :: any)) end do @@ -298,7 +270,7 @@ TEST("graph", function() end do CASE "nodes garbage collection" - local wref = weak { node(1) } + local wref = weak { node(nil, 1) } destroy(wref[1]) gc() CHECK(not wref[1]) @@ -325,41 +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() - set_owner(a, root) - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) - set_owner(e, root) - set_owner(f, root) + local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(root) function b.effect(x) - update(d) + update_descendants(d) return not x end - add_child(a, b); add_child(a, c) - add_child(d, e); add_child(d, f) + push_child(a, b); push_child(a, c) + push_child(d, e); push_child(d, f) - update(a) + update_descendants(a) CHECK(true) end 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" @@ -389,9 +349,6 @@ TEST("mount()", function() end) TEST("root()", function() - local root = vide.root - local cleanup = vide.cleanup - local count = 0 root(function(destroy) @@ -403,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) @@ -470,11 +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 - do CASE "derive new value on source change" local a = source(1) local b = source(2) @@ -562,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 @@ -635,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) @@ -691,15 +636,10 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() - local root = vide.root - local source = vide.source - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "root cleanup" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() cleanup(function() count += 1 end) @@ -772,12 +712,21 @@ TEST("cleanup()", wrap_root(function() 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) @@ -801,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 {}) @@ -859,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") @@ -881,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" { @@ -947,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?) @@ -973,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 @@ -1001,29 +1078,148 @@ TEST("create()", wrap_root(function() end)) TEST("show()", wrap_root(function() - local untrack = vide.untrack - local cleanup = vide.cleanup - local source = vide.source - local effect = vide.effect - local show = vide.show - local root = vide.root + do CASE "show component" + local input = source(true) + local function one() return 1 end - do CASE "main" - -- uses switch() internally, more extensive testing of scoping not needed - local value = source("truey" :: unknown) + local output = show(input, one) + + CHECK(output() == 1) + input(false) + CHECK(output() == nil) + end + + do CASE "fallback component" + local input = source(true) local function one() return 1 end local function two() return 2 end - local output = show(value, one, two) + local output = show(input, one, two) CHECK(output() == 1) - value(nil) + input(false) CHECK(output() == 2) end - do CASE "alt" - local visible = vide.source(true) - local count = vide.source(0) + 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 @@ -1044,7 +1240,6 @@ TEST("show()", wrap_root(function() destroyed += 1 end) end) - return nil end) end) end) @@ -1068,14 +1263,161 @@ TEST("show()", wrap_root(function() 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) @@ -1103,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) @@ -1161,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 @@ -1194,15 +1535,13 @@ TEST("switch()", wrap_root(function() 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 } @@ -1220,7 +1559,7 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local output = vide.root(function() + local _, output = root(function() local output = indexes(input, function(v, i) count[i] += 1 return v @@ -1286,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 } @@ -1320,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 } @@ -1392,14 +1734,84 @@ TEST("indexes()", wrap_root(function() 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 } @@ -1499,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 } @@ -1518,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) @@ -1533,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 @@ -1599,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 @@ -1610,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 source = vide.source - local effect = vide.effect - local derive = vide.derive - local untrack = vide.untrack - local cleanup = vide.cleanup - local root = vide.root - do CASE "does not register dependency" local a = source(0) local b = source(0) @@ -1682,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 @@ -1729,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() @@ -1757,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 @@ -1793,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) @@ -1812,7 +2321,7 @@ TEST("changed()", wrap_root(function() end do CASE "connection disconnected" - local text, destroy = root(function(destroy) + local _, text, destroy = root(function(destroy) local output = source(nil) return create "TextLabel" { @@ -1831,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 @@ -1845,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 @@ -1859,14 +2647,105 @@ TEST("read()", wrap_root(function() end end)) -TEST("nested effects cases", function() - local vide = require "src/init" - local source = vide.source - local effect = vide.effect - local untrack = vide.untrack - local cleanup = vide.cleanup - local root = vide.root +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 @@ -1907,17 +2786,177 @@ TEST("nested effects cases", function() root(App) end) -vide.strict = true +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) @@ -1972,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 } @@ -2035,6 +3064,99 @@ TEST("strict", wrap_root(function() src(not src()) CHECK(count == 4) end + + do CASE "effect using derived source" + local input = source(true) + + local output = derive(function() + return input() + end) + + local count = 0 + + effect(function() + output() + count += 1 + end) + + CHECK(count == 2) + + input(false) + + CHECK(count == 4) + end + + 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 8daf7a7..6f254bd 100644 --- a/todo.md +++ b/todo.md @@ -1,12 +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 -- improve crash course, some sections feel like information dumps -- cleanup source and tests +- improve error traces +- prevent redundant re-eval of nodes in a complex diamond graph diff --git a/wally.toml b/wally.toml index d4d48ed..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.1" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"]