diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..876fbd3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.luau linguist-language=Lua diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 9c22f03..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,33 +0,0 @@ -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 5bb1717..10f047c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,9 @@ name: site-deploy on: + push: + branches: [main] # todo: remove later + workflow_dispatch: permissions: @@ -21,21 +24,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v3 with: node-version: 18 - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v3 - name: Install dependencies run: npm install - name: Build with VitePress run: npm run docs:build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v2 with: path: docs/.vitepress/dist @@ -49,4 +52,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v2 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 138f29b..a6cd4dc 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@v4 + uses: actions/checkout@v3 - name: Install Luau zip - uses: robinraju/release-downloader@v1.9 + uses: robinraju/release-downloader@v1.6 with: repository: Roblox/luau - tag: "0.679" + latest: true fileName: luau-ubuntu.zip out-file-path: bin diff --git a/.github/workflows/wallypesde.yml b/.github/workflows/wallypesde.yml deleted file mode 100644 index 0c705b0..0000000 --- a/.github/workflows/wallypesde.yml +++ /dev/null @@ -1,54 +0,0 @@ -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 18f0f88..bd0480d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,10 @@ .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 3e584d2..2127a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,122 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --------------------------------------------------------------------------------- +## Unreleased -## [0.4.1] - 2026-07-11 - -### Changed - -- `branch()` is now allowed to be used within a reactive scope. - -### Deprecated - -- `create()` overloads. Supported is now only `create(class)(props)`. - -### Fixed - -- `create()` types in the new solver should now work without `::`. - --------------------------------------------------------------------------------- - -## [0.4.0] - 2026-01-17 - -### Added - -- `create("ClassName", { props })` and `create(Instance, { props })` syntax. -- `cleanup()` now accepts `thread` types. -- Implicit effects to set children can now recursively create more implicit - effects to set children. -- `spring()` returns a second value, a setter to set position, velocity and - impulse. -- Improved `spring()` updating and unscheduling. -- `show()` now receives a source to its callback returning the current value - of the condition. -- Ignore `false` passed as a child. -- Flag `vide.defaults` to disable the setting of default properties. -- Delayed scope destruction for control flow functions: `show()` `switch()` `indexes()` `values()`. -- Better `create()` types for the new type solver. - -### Changed - -- A scope can no longer be destroyed while it is active. Strict mode will check - for this. -- Implicit effects to set children now unparent all children when the effect is - destroyed. -- Error reporting should be improved with better formatting when effects invoke - other effects and no more loss of stack traces. -- Nesting parent properties now work, and they are now also checked for - duplicates like other properties. - -### Removed - -- Aggregate construction when setting properties with `create()`. - --------------------------------------------------------------------------------- - -## [0.3.1] - 2024-10-09 - -### Added - -- Context functions now also return results. -- `version` table with current version. - --------------------------------------------------------------------------------- - -## [0.3.0] - 2024-10-06 - -### Added - -- `context()`. - -### Changed - -- `root()` now returns its destructor as the first value by default. - -### Fixed - -- Error stack traces being lost. -- `root()` now destroys the scope automatically if an error occurs during call. - --------------------------------------------------------------------------------- - -## [0.2.0] - 2023-11-22 - -### Added - -- Batched updates with `batch()`. - -### Changed - -- Improved graph updating algorithm. -- Graph nodes when destroyed no longer destroy children; only owned. - -### Fixed - -- Graph edge case where a destroyed node can be readded if it was queued for - rerun before being destroyed. -- Some properties not being applied when `create()` is used recursively. - --------------------------------------------------------------------------------- - -## [0.1.1] - 2023-09-30 - -### Added - -- `cleanup()` accepts objects with a `Destroy()` or `Disconnect()` interface. -- `read()` as a utility to read sources or passthrough a non-source value. - -### Changed - -- Reactive scopes created within reactive scopes are now destroyed on rerun. -- `untrack()` can be called outside of reactive scopes. -- `changed()` will also run its callback with the initial property value. - -### Fixed - -- `show()` and `switch()` not updating when in strict mode. - --------------------------------------------------------------------------------- +--- ## [0.1.0] - 2023-09-20 diff --git a/README.md b/README.md index 13e41b6..8ce077b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ for a quick introduction to the library. ## Code sample -```luau +```lua local create = vide.create local source = vide.source diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 37918e8..18e36cc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,83 +1,67 @@ -//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" } - ] - } -}) +import { defineConfig } from "vitepress" + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "Vide", + titleTemplate: ":title - A reactive UI library for Luau", + description: "A reactive UI library for Luau.", + base: "/vide/", + head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], + + themeConfig: { + logo: "/logo.svg", + + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: "Home", link: "/" }, + { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, + { text: "API", link: "/api/reactivity-core"}, + ], + + sidebar: { + "/api/": [ + { + text: "API", + items: [ + { text: "Reactivity: Core", link: "/api/reactivity-core" }, + { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, + { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, + { text: "Element Creation", link: "/api/creation" }, + { text: "Animation", link: "/api/animation" }, + { text: "Strict Mode", link: "/api/strict-mode" }, + ] + } + ], + + "/tut/": [ + { + text: "Crash Course", + items: [ + { text: "Introduction", link: "/tut/crash-course/1-introduction" }, + { text: "Element Creation", link: "/tut/crash-course/2-creation" }, + { text: "Components", link: "/tut/crash-course/3-components" }, + { text: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Stateful Components", link: "/tut/crash-course/6-stateful-component" }, + { text: "Property Binding", link: "/tut/crash-course/7-property-binding" }, + { text: "Cleanup", link: "/tut/crash-course/8-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/9-control-flow" }, + { text: "Property Nesting", link: "/tut/crash-course/10-property-nesting" }, + { text: "Actions", link: "/tut/crash-course/11-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/12-strict-mode" }, + ] + }, + { + text: "Advanced Reactivity", + items: [ + { text: "Reactive Scopes", link: "/tut/advanced/reactive-scoping.md"} + ] + } + ], + }, + + socialLinks: [ + { icon: "github", link: "https://github.com/centau/vide" } + ] + } +}) diff --git a/docs/.vitepress/theme/index.css b/docs/.vitepress/theme/index.css deleted file mode 100644 index 9c3ca9a..0000000 --- a/docs/.vitepress/theme/index.css +++ /dev/null @@ -1,25 +0,0 @@ -.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 54a4c62..b8b9aa6 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -1,8 +1,4 @@ -// .vitepress/theme/index.js -import DefaultTheme from 'vitepress/theme' -import './vars.css' -import './index.css' - -export default { - extends: DefaultTheme, -} +// .vitepress/theme/index.js +import DefaultTheme from 'vitepress/theme' +import './vars.css' +export default DefaultTheme diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css index 7fbf3b5..64df54f 100644 --- a/docs/.vitepress/theme/vars.css +++ b/docs/.vitepress/theme/vars.css @@ -1,52 +1,3 @@ -/* 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; -} - +:root { + --vp-c-brand-1: #3086ff; +} diff --git a/docs/api/animation.md b/docs/api/animation.md index ca06b39..16e48d7 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -1,30 +1,25 @@ -# Animation +# Animation API -## spring() REACTIVE +## spring() Returns a new source with a value always moving torwards the input source value. - **Type** - ```luau + ```lua function spring( source: () -> T & Animatable, period: number = 1, damping_ratio: number = 1 - ): (() -> T, SpringControl) + ): () -> T type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect - - type SpringControl = ({ - position: T?, - velocity: T?, - impulse: T? - }) -> () ``` - **Details** - Creates a reactive scope internally to detect source updates. + The output source value is updated every step based on the input source + value. The movement is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). @@ -44,7 +39,3 @@ 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 578b10f..f5b4c1c 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -1,4 +1,38 @@ -# Element Creation +# Element Creation API + +
+ +## mount() + +Runs a function and applies its result to a target instance. + +- **Type** + + ```lua + function mount(component: () -> T, target: Instance?): () -> () + ``` + +- **Details** + + The result of the function is applies to the target in the same way + properties are using `create()`. + + The function is ran in a new reactive scope, just like + [root()](reactivity-core.md#root). + + Returns a function that when called will destroy the reactive scope. + +- **Example** + + ```lua + local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "Vide" } + } + end + + mount(App, game.StarterGui) + ``` ## create() @@ -6,11 +40,11 @@ Creates a new UI element, applying any given properties. - **Type** - ```luau + ```lua function create(class: string): (Properties) -> Instance function create(instance: Instance): (Properties) -> Instance - type Properties = Map + type Properties = Map ``` - **Details** @@ -26,68 +60,86 @@ Creates a new UI element, applying any given properties. - **Property setting rules** - - **index is string:** - - **value is function:** - - **property is event:** connect function as callback - - **property is not event:** create effect to update property - - **value is not function:** set property to value - - **index is number:** - - **value is action:** run action - - **value is table:** recurse table - - **value is function:** create effect to update children - - **value is instance:** set instance as child + - If a table index is a string: + - If its value is a function then it will either bind that property to + the function or connect it if the property type is a `RBXScriptSignal`. + - If the value is not a function then the property will be set to that + value. + - If a table index is a number: + - If its value is an action then that action will be queued to run after + properties are set. + - If its value is a table then that table will be recursively + processed just like the outer table. + - If its value is a function then it will bind the instances children to + that function. + - If its value is an instance then it will be parented to the instance. - **Example** Basic element creation. - ```luau - local frame = create "TextButton" { - Name = "Button", - Size = UDim2.fromOffset(200, 160), - - Activated = function() - print "clicked" - end, - - create "UICorner" {} + ```lua + local frame = create "Frame" { + Name = "NewFrame", + Position = UDim2.fromScale(1, 0) } ``` + 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 special object that can be passed to `create()` to invoke custom -actions on instances. +Creates a callback that can be passed to `create()` to invoke custom actions on +instances. - **Type** - ```luau + ```lua function action((Instance) -> (), priority: number = 1): Action ``` - **Details** - 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. + 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. A priority can be optionally specified to ensure certain actions run after - other actions. Lower priority values are ran first. + other actions. Higher priority numbers are ran after lower priority numbers. - **Example** An action to listen to changed properties: - ```luau - local function changed(property: string, fn: (new) -> ()) + ```lua + local function changed(property: string, callback: (new) -> ()) return action(function(instance) - local cn = instance:GetPropertyChangedSignal(property):Connect(function() - fn(instance[property]) - end) - - -- disconnect on scope destruction to allow gc of instance - cleanup(function() - cn:Disconnect() + instance:GetPropertyChangedSignal("property"):Connect(function() + callback(instance[property]) end) end) end @@ -95,58 +147,7 @@ actions on instances. local output = source "" create "TextBox" { - -- will update the output source anytime the text property is changed + -- will update the `output` source anytime the text property is changed changed("Text", output) } ``` - -## changed() - -A wrapper for `action()` to listen for property changes. - -- **Type** - - ```luau - function changed(property: string, fn: (unknown) -> ()): Action - ``` - -- **Details** - - Will run the given function immediately and whenever the property updates. - - The function is called with the updated property value. - - Runs with an action priority of 1. - -## mount() STABLE - -Runs a function in a new stable scope and optionally applies its result to a -target instance. - -- **Type** - - ```luau - function mount(component: () -> T, target: Instance?): () -> () - ``` - -- **Details** - - This is a utility for `root()` when parenting a component to an existing - instance. - - The result of the function is applied to a target in the same way - properties are using `create()`. - - Returns a function that when called will destroy the stable scope. - -- **Example** - - ```luau - local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "Vide" } - } - end - - local destroy = mount(App, game.StarterGui) - ``` diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index ebbc5dd..0e4a5d0 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -1,155 +1,132 @@ -# Reactivity: Core +# Reactivity API: Core -## Scopes +
-Vide code can run in one of two scopes: STABLE or REACTIVE. +## root() -- Reactive scopes rerun if a source read within updates. -- Stable scopes never rerun. -- Reactive scopes cannot be created directly within another reactive scope. -- When a scope is destroyed, all scopes created within are also destroyed. - -Different functions in Vide's API will run code in different scopes. - -:::warning -Yielding is not allowed in any stable or reactive scope. Strict mode will check -for this. -::: - -## root() STABLE - -Runs a function in a new stable scope. +Creates and runs a function in a new reactive scope. - **Type** - ```luau - function root(fn: (Destructor) -> T...): (Destructor, T...) - - type Destructor = () -> () + ```lua + function root(fn: (destroy: () -> ()) -> T...): T... ``` - **Details** - Returns a destructor and any values returned by the callback. + Creates a new root reactive scope, where creation and derivations of sources + can be tracked and properly disposed of. + + Returns the result of the given function. + + A function to destroy the root is passed into the callback, which will run + any cleanups and allow derived sources created to garbage collect. + + ::: warning + `fn()` cannot yield. + ::: ## source() -Creates a new source. +Creates a new source with the given value. - **Type** - ```luau - function source(value: T): Source - - type Source = - () -> T -- get - & (T) -> () -- set + ```lua + function source(value: T): (T?) -> T ``` - **Details** - Call the returned source with no argument to read its value. - Call the returned source with an argument to set its value. + Calling the returned source with no argument will return its stored value, + calling with an argument will set a new value. + + Reading from the source from within a reactive scope will cause changes + to that source to be tracked and anything depending on it to update. - **Example** - ```luau + ```lua local count = source(0) - print(count())-- 0 - count(count() + 1) - print(count()) -- 1 + + count() -- 0 + + count(count() + 1) -- 1 ``` -## effect() REACTIVE +## effect() -Runs a function in a new reactive scope. +Runs a side-effect on source update. - **Type** - ```luau - function effect(fn: () -> ()) + ```lua + function effect(callback: () -> ()) ``` - **Details** - The function is ran once immediately. + The callback is ran immediately. + + Any time a source referenced in the callback is changed, the callback will + be reran. + + ::: warning + `callback()` cannot yield. + ::: - **Example** - ```luau - local count = source(1) + ```lua + local num = source(1) effect(function() - print(count()) + print(num()) end) -- prints 1 - count(2) + num(num() + 1) -- prints 2 ``` -## derive() REACTIVE +## derive() -Runs a function in a new reactive scope to compute a value for new source. +Derives a new source from existing sources. - **Type** - ```luau - function derive(fn: () -> T): () -> T + ```lua + function derive(source: () -> T): () -> T ``` - **Details** - Anytime the reactive scope reruns, the output source value is set to what is - returned. + The derived source will have its value recalculated when any source source + it derives from is updated. - The function is ran once immediately. + Anytime its value is recalculated it is also cached, subsequent calls will + retun this cached value until it recalculates again. + + Takes a callback that is immediately run to determine what sources are being + referenced. + + ::: warning + `source()` cannot yield. + ::: - **Example** - ```luau + ```lua local count = source(0) local text = derive(function() return `count: {count()}` end) - print(text()) -- "count: 0" + text() -- "count: 0" count(1) - print(text()) -- "count: 1" + 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 deleted file mode 100644 index f0b3229..0000000 --- a/docs/api/reactivity-dynamic.md +++ /dev/null @@ -1,229 +0,0 @@ -# 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 new file mode 100644 index 0000000..47dafa8 --- /dev/null +++ b/docs/api/reactivity-flow.md @@ -0,0 +1,218 @@ +# 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 b8bff4d..9219b29 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -1,58 +1,47 @@ -# Reactivity: Utility +# Reactivity API: Utility ## cleanup() -Queues a callback to run when a scope is reran or destroyed. +Runs a callback anytime a reactive scope is re-ran. - **Type** - ```luau - function cleanup(v: Function | Disconnectable | Destroyable | thread) - - type Function = () -> () - type Destroyable = { destroy: () -> () } - type Disconnectable = { disconnect: () -> () } + ```lua + function cleanup(callback: () -> ()) ``` - **Example** - ```luau - local count = source(0) + ```lua + local data = source(1) - local destroy = root(function() - effect(function() - count() + effect(function() + local label = create "TextLabel" { Text = data() } - cleanup(function() - print "cleaned" - end) + cleanup(function() + label:Destroy() end) - end - - -- nothing printed yet - count(1) -- prints "cleaned" - count(2) -- prints "cleaned" - destroy() -- prints "cleaned" + end) ``` -## untrack() STABLE +## untrack() -Runs a function in a new stable scope. +Runs a given function where any sources read will not track its reactive scope. - **Type** - ```luau + ```lua function untrack(source: () -> T): T ``` - **Details** - Can be used inside a reactive scope to read from sources you do not want - tracked by the reactive scope. + Updates made to a source passed to `untrack()` will not cause updates to + anything depending on that source. - **Example** - ```luau + ```lua local a = source(0) local b = source(0) @@ -61,9 +50,9 @@ Runs a function in a new stable scope. end) print(sum()) -- 0 - b(1) -- untracked so reactive scope created by derive() does not rerun + b(1) print(sum()) -- 0 - a(1) -- reactive scope created by derive() reruns + a(1) print(sum()) -- 2 ``` @@ -73,86 +62,8 @@ Utility used to read a value that is either a primitive or a source. - **Type** - ```luau + ```lua 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 6f9ee80..35de4d8 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: -```luau +```lua vide.strict = true ``` @@ -14,24 +14,24 @@ and identifying improper usage. Currently, strict mode will: -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. +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. -By rerunning reactive scopes twice each time they update, it helps ensure that -computations are pure, and that any cleanup is done correctly. +By rerunning sources and effects, any side-effects are made more apparent. +This also helps ensure that cleanups are being handled correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, -which strict mode will catch. +which strict mode can catch. As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly -when implicit effects are created for instance property updating. +when binding properties to sources. -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 +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 need to worry about disabling strict mode unless you have manually enabled it. diff --git a/docs/index.md b/docs/index.md index 295d137..6fe3a4a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,16 +1,14 @@ --- # https://vitepress.dev/reference/default-theme-home-page layout: home -pageClass: home -next: - text: 'Introduction' - link: '/tut/crash-course/1-introduction' hero: - name: "Vide" + name: Vide + text: "" tagline: A reactive UI library for Luau. image: src: /logo.svg + alt: Vide actions: - theme: brand text: Tutorials @@ -19,4 +17,8 @@ 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 a329007..921ee39 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,12 +1,13 @@ -{ - "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" - } -} +{ + "type": "module", + + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + + "devDependencies": { + "vitepress": "^1.0.0-rc.4" + } +} diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg index 1962800..85488e0 100644 --- a/docs/public/full_logo.svg +++ b/docs/public/full_logo.svg @@ -1,4 +1,4 @@ - + diff --git a/docs/public/logo.svg b/docs/public/logo.svg index 22d840a..92b06c1 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,31 +1,37 @@ - - - - - + + + + + - + - - - - + + + - + - - - + + + - + + + + + + + + - + - + diff --git a/docs/tut/advanced/dynamic-scopes.md b/docs/tut/advanced/dynamic-scopes.md deleted file mode 100644 index 139a19c..0000000 --- a/docs/tut/advanced/dynamic-scopes.md +++ /dev/null @@ -1,148 +0,0 @@ -# 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 new file mode 100644 index 0000000..5b983ab --- /dev/null +++ b/docs/tut/advanced/reactive-scoping.md @@ -0,0 +1,61 @@ +# Reactive Scoping + +This is a brief document designed to give the user more insight into how Vide's +reactive graph works. + +Each time you create and derive sources, a new node representing that source is +created and added to the reactive graph. Each node stores a value and a +side-effect function. Each node also keeps track of its parents and children, +as well as any cleanups registered. + +Any time a node is updated, Vide will traverse and update that node's children, +its children's children, etc, until all nodes descending from that node has been +updated. Traversal will stop at a node if that node's cached value does not +change after an update. + +For every node that is updated, a scope is opened for that node. These scopes +are referred to as "reactive scopes". Any source read from within a node's scope +will that node as a child. This is similar to cleanups, anytime a cleanup is +registered, it is added to the node of the currently active scope. + +The way Vide tracks reactive scopes, is by using a stack of nodes. The current +active reactive scope is the node at the top of this stack. + +When destroying a node, its descendents are traversed and also destroyed. +When being destroyed, a node's connections (parents and children) are cleared, +and any pending cleanup functions are ran. + +The purpose of `root()` (which is called internally by `mount()`) is to setup +the root node which will track any node created or derived inside its scope, or +any cleanups registered. Without it, nodes could be garbage collected without a +chance to run pending cleanups which can cause memory leakage. + +Control flow functions in Vide are special, as they can dynamically create and +destroy new root scopes. + +It is the combination of the above which allows us to write components like so: + +```lua +local function Counter() + local count = source(0) + + local connection = stepped:Connect(function() count(count() + 1) end) + + cleanup(function() connection:Disconnect() end) + effect(function() print(count()) end) + + return create "TextLabel" { Text = count } +end +``` + +Vide doesn't recognise this as a "component", that is a user abstraction. Vide +just sees this as a function that creates nodes in the reactive graph. + +Whenever the reactive scope that calls this function is destroyed, like by a +control flow function, the registered cleanup will be called, and the effect +(which is just a node on the reactive graph) is destroyed. The returned instance +and the bound `count` source is just considered to be a side-effect, and with +the reactive scope from which the side-effects stem from destroyed, the instance +can be garbage collected - everything is nicely cleaned up. + +> todo: add graphics diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md new file mode 100644 index 0000000..8628e45 --- /dev/null +++ b/docs/tut/control-flow/2-show.md @@ -0,0 +1 @@ +# show() diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md new file mode 100644 index 0000000..647835a --- /dev/null +++ b/docs/tut/control-flow/3-switch.md @@ -0,0 +1 @@ +# switch() diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md new file mode 100644 index 0000000..aad2c90 --- /dev/null +++ b/docs/tut/control-flow/4-indexes.md @@ -0,0 +1 @@ +# indexes() diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md new file mode 100644 index 0000000..8909904 --- /dev/null +++ b/docs/tut/control-flow/5-values.md @@ -0,0 +1 @@ +# values() diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 564aa2d..371950a 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -1,21 +1,42 @@ # Introduction -This is a tutorial that introduces the concepts and usage of Vide. +This is a brief tutorial designed to give you a quick run through the usage of +Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). ## Why Vide? -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. +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. -Some of Vide's main design choices: +Some of the main focuses behind Vide's design choices: -- Syntax minimal. -- Data oriented. -- Typechecking compatible. -- Instance independent. +- Concise syntax to reduce verbosity as much as possible. +- Being completely typecheckable. +- Independence from instance lifetimes. +- A powerful reactive system that can update specific properties as a result of + state changes, updates are immediate with no diffing needed. -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. +## Structure Of A Vide App + +The entry point for all Vide apps is the `mount()` function. This function +sets up Vide's reactivity system. It takes and calls a function that should +create your entire app, and will apply its result to a target. + +In Vide, your app should be composed of functions, each function creates a +specific part of your app, and can be reused if needed. These functions are +called *components*. + +```lua +local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "hi" } + } +end + +mount(App, game.StarterGui) +``` diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md deleted file mode 100644 index e25d451..0000000 --- a/docs/tut/crash-course/10-cleanup.md +++ /dev/null @@ -1,38 +0,0 @@ -# Cleanup - -Sometimes you may need to do some cleanup when destroying a component or after -a side-effect from a source update. Vide provides a function `cleanup()` which -is used to queue a callback for the next time a reactive scope is rerun or -destroyed, or when a stable scope is destroyed. - -```luau -local root = vide.root -local source = vide.source -local effect = vide.effect -local cleanup = vide.cleanup - -local count = source(0) - -local destroy = root(function() - effect(function() - local x = count() - cleanup(function() print(x) end) - end) - - cleanup(function() print "root destroyed" end) -end) - -count(1) -- prints "0" -count(2) -- prints "1" -destroy() -- prints "2" and "root destroyed" -``` - -::: tip -Roblox instances do not need to be explicitly destroyed for their -memory to be freed, they only need to be parented to `nil`. So there is no -need to use `cleanup()` to destroy instances. However, be wary of connecting -a function that references an instance to an event from the same instance, -this causes the instance to reference itself and never be freed. In such a case -you would need to use `cleanup()` to disconnect this connection or to explicitly -destroy the instance. -::: diff --git a/docs/tut/crash-course/10-property-nesting.md b/docs/tut/crash-course/10-property-nesting.md new file mode 100644 index 0000000..2aabb11 --- /dev/null +++ b/docs/tut/crash-course/10-property-nesting.md @@ -0,0 +1,120 @@ +# Nested Properties + +Often when creating components from existing components, you can find yourself +repetitively passing through properties such as size or position. + +Example below: + +```lua +function Background(props: { + Color: Color3, + AnchorPoint: UDim2, + Position: UDim2, + Size: UDim2 +}) + return create "Frame" { + Color = props.Color + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size + } +end + +function Menu(props: { + Color = props.Color + AnchorPoint: UDim2, + Position: UDim2, + Size: UDim2 +}) + return Background { + Color = props.COlor, + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size + } +end +``` + +One way this can be avoided is by using *property nesting*. In Vide, passing a +table value inside `props` has special semantics. Any key with a table value is +not assigned like a property, instead the table is iterated and processed just +like the outer table is. Any properties in the nested table will be assigned +to the instance just the same. + +Below is an example of how you can use this to pass groups of similar properties +together such as position and size, while also using typechecking. + +```lua +type Layout = { + Layout = { + Position: UDim2?, + Size: UDim2?, + AnchorPoint: Vector2? + } +} + +function Background(props: Layout & { Color: Color3 }) + return create "Frame" { + Color = props.Color, + props.Layout + } +end + +function Menu(props: Layout & { Color: Color3 }) + return Background { + Color = props.Color, + Layout = props.Layout + } +end +``` + +Here we created a nested group with the key `Layout` that can accept +layout-related properties. Any name could be chosen for the key. +This allows us to write much more concise syntax that is also typecheckable. + +In another example we use a key named `Children` to pass arrays of instances to +be parented. + +```lua +type Children = { + -- allows us to also optionally pass a source that returns an array of children instead + Children = Array | () -> Array +} + +local function List(props: Children & Layout) + return create "Frame" { + props.Children, + props.Layout, + create "UIListLayout" {} + } +end + +List { + Layout = { + Position = UDim2.new() + }, + + Children = { + create "TextLabel" { Text = "1" }, + create "TextLabel" { Text = "2" } + } +} +``` + +Deeper nested properties are guaranteed to be set after shallower nested +properties, this can be used to create overridable default properties. + +```lua +local function List(props: Children & Layout) + return create "Frame" { + props.Children, + props.Layout, + + -- can be overriden by `props.Layout` + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.fromScale(0.5, 0), + + create "UIListLayout" {} + } +end +``` diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/11-actions.md similarity index 57% rename from docs/tut/crash-course/12-actions.md rename to docs/tut/crash-course/11-actions.md index 60e5c30..79ca7c7 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/11-actions.md @@ -1,11 +1,14 @@ # Actions -Actions are special callbacks that you can pass along with properties, -to run some code on an instance receiving them. +Actions in Vide are special callbacks that you can pass along with properties, +which will be called when those properties are being processed with the instance +being assigned to, allowing you to run custom code. -```luau +```lua local action = vide.action +``` +```lua create "TextLabel" { Text = "test", @@ -17,24 +20,24 @@ create "TextLabel" { -- will print "test" ``` -Actions can be wrapped with functions for reuse. Below is an example of an -action used to listen for property changes: +Actions can be wrapped with functions to re-use specific behaviors. Below is +an example of an action used to listen for property changes: -```luau +```lua local action = vide.action -local source = vide.source -local effect = vide.effect local cleanup = vide.cleanup local function changed(property: string, callback: (new) -> ()) return action(function(instance) - local connection = instance:GetPropertyChangedSignal(property):Connect(function() + local con = instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) -- remember to clean up the connection when the reactive scope the action -- is ran in is destroyed, so the instance can be garbage collected - cleanup(connection) + cleanup(function() + con:Disconnect() + end) end) end @@ -44,11 +47,9 @@ local instance = create "TextBox" { changed("Text", output) } -effect(function() - print(output()) -end) +instance.Text = "foo" -instance.Text = "foo" -- "foo" will be printed by the effect +print(output()) -- "foo" ``` The source `output` will be updated with the new property value any time it is diff --git a/docs/tut/crash-course/11-dynamic-scopes.md b/docs/tut/crash-course/11-dynamic-scopes.md deleted file mode 100644 index 1c12e51..0000000 --- a/docs/tut/crash-course/11-dynamic-scopes.md +++ /dev/null @@ -1,211 +0,0 @@ -# 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-strict-mode.md b/docs/tut/crash-course/12-strict-mode.md similarity index 66% rename from docs/tut/crash-course/13-strict-mode.md rename to docs/tut/crash-course/12-strict-mode.md index 0de8887..3cb09e6 100644 --- a/docs/tut/crash-course/13-strict-mode.md +++ b/docs/tut/crash-course/12-strict-mode.md @@ -5,15 +5,11 @@ be set with `vide.strict = true` once when you first require Vide. Strict mode will add extra safety checks and emit better error traces, particularly when errors occur in property bindings. -Strict mode is automatically enabled when Vide is required in O0 or O1 -optimization (default studio level). You can `vide.strict = false` if you do not -want this. - Strict mode will run derived sources and effects twice each time they update. -This is to help ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done properly. +This is to help identify improper cleanup of side-effects and ensure that pure +computations are actually pure. -```luau +```lua local source = vide.source local effect = vide.effect @@ -23,7 +19,6 @@ 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 deleted file mode 100644 index f33d043..0000000 --- a/docs/tut/crash-course/14-concepts.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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 668a470..68e3007 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -2,37 +2,62 @@ Instances are created using `create()`. -Parentheses `()` can be omitted when calling functions with string or -table literals for brevity. +`create()` returns a constructor for a class which then takes a table of +properties to assign when creating a new instance for that class. -```luau +Luau allows us to omit parentheses `()` when calling functions with string or +table literals which Vide takes advantage of for brevity. + +```lua +local mount = vide.mount local create = vide.create -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), +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), - create "TextLabel" { - Text = "hi" - }, + create "TextLabel" { + Text = "hi" + }, - create "TextLabel" { - Text = "bye" - }, + create "TextLabel" { + Text = "bye" + }, - create "TextButton" { - Text = "click me", + create "TextButton" { + Text = "click me", - Activated = function() - print "clicked!" - end + Activated = function() + print "clicked!" + end + } } } -} +end + +mount(App, game.StarterGui) ``` Assign a value to a string key to set a property, and assign a value to a number key to set a child. Events can be connected to by assigning a function to a string key. + +You can also use a shorthand to create datatypes instead of explicitly typing +out the class name and constructor. The table will be unpacked into the `.new()` +constructor of the property's type. + +```lua +create "Frame" { + AnchorPoint = { 0.5, 1 }, + UDim2 = { 0.5, 0, 0.5, 0 } +} +``` + +When creating an instance with no properties, it is important to not forget to +actually call the constructor: `create "Frame" {}` and not `create "Frame"`. +To be clear, `create "Frame"` returns a *function* which is a constructor for +that class, not an instance of that class. This would result in you attempting +to parent a function instead of an instance which is not the correct behavior. diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 0b803ca..3050cf8 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,16 +1,11 @@ # Components -Vide encourages separating different parts of your UI into functions called -*components*. +Components are custom-made reusable pieces of UI made from other pieces of UI. -A component is a function that creates and returns a piece of UI. +By using components you can make your application more modular and better +organized. -This is a way to separate your UI into small chunks that you can reuse and put -together. - -::: code-group - -```luau [Button.luau] +```lua [Button.luau] local create = vide.create local function Button(props: { @@ -20,46 +15,50 @@ local function Button(props: { }) return create "TextButton" { BackgroundColor3 = Color3.fromRGB(50, 50, 50), - TextColor3 = Color3.fromRGB(255, 255, 255), Size = UDim2.fromOffset(200, 150), Position = props.Position, Text = props.Text, - Activated = props.Activated, - - create "UICorner" {} + Activated = props.Activated } end return Button ``` -```luau [Menu.luau] +```lua [App.luau] +local mount = vide.mount local create = vide.create local Button = require(Button) -local function Menu() +local function App() return create "ScreenGui" { Button { Position = UDim2.fromOffset(200, 200), - Text = "back", - Activated = function() - print "go to previous page" - end - }, + Text = "click me!", - Button { - Position = UDim2.fromOffset(400, 200), - Text = "next", Activated = function() - print "go to next page" + print "clicked" end } } end + +mount(App, game.StarterGui) ``` -::: +Above is a simple example of a button component with a set color and size, +being reused across files. A single parameter `props` is used to pass properties to the component. + +Components allow you to *encapsulate* behavior. You can only modify the +component in ways that you allow in the component, through the `props` parameter. + +To create a new button all you must do is call the `Button` function, passing in +values through props. This saves having to create and set every property each +time. Also, when updating the button component in future, any changes to the +button file will be seen anywhere the button is used in your app. + +This can be extended to much more complicated UI. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index d862156..1583c59 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,11 +1,12 @@ -# Sources +# Source -Sources are special objects that store a single value and are the core of -Vide's reactivity. +*Sources* in Vide are special objects that store a single value. They are the +core of reactivity in Vide. Each source represents a source of data, and they +can be composed and derived to create new sources of data. -A source can be created using `source()`. +A source in Vide can be created using `source()`. -```luau +```lua local source = vide.source local count = source(0) @@ -16,13 +17,14 @@ 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. -```luau +```lua count(count() + 1) -- increment count by 1 ``` -Sources can be *derived* by wrapping them in functions. +Sources can be *derived* by wrapping them in functions. A wrapped source +effectively becomes a new source. -```luau +```lua local count = source(0) local text = function() @@ -34,5 +36,8 @@ count(1) print(text()) -- "count: 1" ``` -While the above can be achieved with plain variables, the use for sources will -be obvious in the next part. +You may be wondering why we are using sources instead of plain variables to do +this. The reason is that Vide has an entire reactive system based on sources. +You can write functions to automatically run each time a source is updated. This +can be to update properties, create new instances, print to the terminal, etc. +How this is done will be covered next. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 82768dc..3891652 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,11 +1,11 @@ -# Effects +# Effect -Effects are functions that are ran in response to source updates. -A source and effect is analogous to a signal and connection. +Effects are functions that are ran in response to source updates. They are +alled effects because they cause *side-effects* when reacting to source updates. Effects are created using `effect()`. -```luau +```lua local source = vide.source local effect = vide.effect @@ -20,16 +20,17 @@ count(1) -- "count: 1" printed ``` -Any source read inside an effect is tracked and will rerun the effect when -that source is updated. +The callback given to `effect()` is ran in a *reactive-scope*. Any source read +from inside a reactive scope will be tracked, so that if any of those sources +update, the effect will be re-ran too. -The effect runs its callback once immediately to initially figure out what -sources are being read. +The callback is first ran immediately inside the `effect()` call to initially +figure out what sources are being used. -Derived sources are also tracked, it does not matter how deeply nested -inside a function a source is. +Effects also work with derived sources, it doesn't matter how deeply nested a +source is. -```luau +```lua local source = vide.source local effect = vide.effect @@ -48,24 +49,27 @@ count(2) -- "doubled count: 4" printed ``` -If a source is updated with the same value it already had, it will not rerun -effects depending on it. +Derived sources should be a *pure computation*. A pure computation is one where +the same input will always produce the same output. -You can also read from a source within an effect without the effect tracking it. +All observable changes to the user are considered to be side-effects of pure +computations. -```luau -local source = vide.source -local effect = vide.effect -local untrack = vide.untrack +Sources, derived sources, and effects form what is called a *reactive graph*. +In the above example a graph `count -> doubled -> effect` is formed. Anywhere +an update occures, everything further down the graph is updated. -local a = source(0) -local b = source(0) +You should not update other sources using an effect. Improper usage can lead to +a cyclic loop in the graph, causing an infinite loop when it tries to update. +Sources should be derived instead. -effect(function() - print(`a: {a()} b: {untrack(b)}`) -end) +## Root Reactive Scopes -a(1) -- prints "a: 1 b: 0" -b(1) -- prints nothing -a(2) -- prints "a: 2 b: 1" -``` +Effects must be created within another reactive scope. This is so that the +effect itself can be tracked and later freed when the parent reactive scope is +destroyed, such as from unmounting an app. The example code above will not +actually work unless it is ran inside a root reactive scope, such as one created +by `vide.mount(function)`. This generally isn't a concern since you can assume +that all your components will be created within a single `mount()` call, which +happens only once at the top level, where you put together your UI and parent it +to a ScreenGUI. diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md deleted file mode 100644 index e2cea69..0000000 --- a/docs/tut/crash-course/6-scope.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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-reactive-component.md b/docs/tut/crash-course/6-stateful-component.md similarity index 60% rename from docs/tut/crash-course/7-reactive-component.md rename to docs/tut/crash-course/6-stateful-component.md index 339ee4f..ba167e2 100644 --- a/docs/tut/crash-course/7-reactive-component.md +++ b/docs/tut/crash-course/6-stateful-component.md @@ -1,9 +1,11 @@ -# Reactive Components +# Stateful Component -Reactive components in Vide are created using sources and effects - sources to +A stateful component is a component that stores and displays some data. + +Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. -```luau +```lua local create = vide.create local source = vide.source local effect = vide.effect @@ -21,7 +23,7 @@ local function Counter() instance.Text = "count: " .. count() end) - return instance + return count end ``` @@ -29,32 +31,36 @@ Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the -count source is created inside the component. +count source is created inside the scope of the component. External sources can also be passed into components for them to use. -```luau -local function CountDisplay(props: { count: () -> number }) +```lua +local function Counter(props: { count: () -> number }) local count = props.count - local instance = create "TextLabel" {} + local instance = create "TextButton" { + Activated = function() + count(count() + 1) + end + } effect(function() instance.Text = "count: " .. count() end) - return instance + return count end local count = source(0) -CountDisplay { +Counter { count = count } -count(1) -- the CountDisplay component will update to display this count +count(1) -- the Counter component will update to display this count ``` Sources can be created internally or passed in from externally, there are no -restrictions on how they are used as long as the effect using it is created -within a stable scope. +restrictions on how they are used as long as the effect is created within a +reactive scope so that it can be tracked. diff --git a/docs/tut/crash-course/7-property-binding.md b/docs/tut/crash-course/7-property-binding.md new file mode 100644 index 0000000..b3840cc --- /dev/null +++ b/docs/tut/crash-course/7-property-binding.md @@ -0,0 +1,64 @@ +# Property Binding + +Explicitly creating effects to update properties can become verbose when there +are a lot of properties to update. Vide provides a way to *implicitly* create +an effect to update properties on source update. This is also known as +*property binding*, as a property is binded to reflect some data. + +```lua +local create = vide.create +local source = vide.source + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = function() + return "count: " .. count() + end, + + Activated = function() + count(count() + 1) + end + } +``` + +This example is equivalent to the example seen on the previous page. + +Instead of explicitly creating an effect, assigning a (non-event) property +a function will implicitly create a side-effect to update that property anytime +a dependent source is updated. + +Just like effects, the function is ran immediately in a reactive-scope to set +the property initially and determine what sources are being depended on. + +This allows you as the programmer to not need to manually update UI as the state +of your program changes. You just define how the data maps to UI, and Vide's +reactive system will automatically update any properties depending on sources +that are updated. + +## Children Binding + +Children can also be set in a similar manner. + +```lua +local items = source { + create "TextLabel" { Text = "A" } +} + +local function List(props: { children: () -> { Instance } }) + return create "Frame" { + create "UIListLayout" {}, + props.children + } +end + +local list = List { children = items } -- creates a list with a single text label "A" + +items { + create "TextLabel" { Text = "B" }, + create "TextLabel" { Text = "C" } +} + +-- this will automatically unparent the text label "A", and parent the labels "B" and "C". +``` diff --git a/docs/tut/crash-course/8-cleanup.md b/docs/tut/crash-course/8-cleanup.md new file mode 100644 index 0000000..3a04463 --- /dev/null +++ b/docs/tut/crash-course/8-cleanup.md @@ -0,0 +1,48 @@ +# Cleanup + +Sometimes you may need to do some cleanup when destroying a component or after +a side-effect from a source update. Vide provides a function `cleanup()` which +is used to register a cleanup callback for the next time the reactive scope +it is called in re-runs. + +```lua +locla mount = vide.mount +local source = vide.source +local cleanup = vide.cleanup + +local function Timer() + local count = source(0) + + local con = game:GetService("RunService").Heartbeat:Connect(function(dt) + count(count() + dt) + end) + + cleanup(function() + con:Disconnect() + end) + + return create "TextButton" { + Position = UDim2.fromOffset(300, 300), + Size = UDim2.fromOffset(200, 50), + + Text = function() + return "seconds: " .. math.floor(count()) + end, + } +end + +local unmount = mount(Timer) + +unmount() -- all registered cleanups are ran, heartbeat connection stopped +``` + +In the above example, this allows us to disconnect the heartbeat connection +when the timer component is destroyed, whether that is from unmounting the app +or if it is dynamically created by a control-flow function, which will be +covered next. + +On a related note: the reason why `mount()` is used to create your app, is so +that any top-level components that need to be cleaned up, can be cleaned up +when the app is later unmounted, since `mount()` runs in a reactive-scope to +track `cleanup()` calls. Vide's entire reactive system is independent from the +life-time of instances; instances are just a side-effect of the reactive system. diff --git a/docs/tut/crash-course/8-implicit-effect.md b/docs/tut/crash-course/8-implicit-effect.md deleted file mode 100644 index 3363bf6..0000000 --- a/docs/tut/crash-course/8-implicit-effect.md +++ /dev/null @@ -1,83 +0,0 @@ -# Implicit Effects - -Explicitly creating effects to update properties is tedious. You can -*implicitly* create an effect to update properties instead. - -::: code-group - -```luau [Implicit Effect] -local create = vide.create -local source = vide.source - -local function Counter() - local count = source(0) - - return create "TextButton" { - Activated = function() - count(count() + 1) - end, - - Text = function() - return "count: " .. count() - end - } -end -``` - -```luau [Explicit Effect] -local create = vide.create -local source = vide.source -local effect = vide.effect - -local function Counter() - local count = source(0) - - local instance = create "TextButton" { - Activated = function() - count(count() + 1) - end - } - - effect(function() - instance.Text = "count: " .. count() - end) - - return instance -end -``` - -::: - -This example is equivalent to the example seen on the previous page. - -Instead of explicitly creating an effect, assigning a (non-event) property a -function will implicitly create an effect to update that property. - -## Children - -Children can also be set in a similar manner. A source passed as a child (passed -with a number key instead of string key) can return an instance or an array of -instances. An effect is automatically created to unparent removed instances and -parent new instances on source update. - -```luau -local items = source { - create "TextLabel" { Text = "A" } -} - -local function List(props: { children: () -> { Instance } }) - return create "Frame" { - create "UIListLayout" {}, - props.children - } -end - -local list = List { children = items } -- creates a list with text label "A" - -items { - create "TextLabel" { Text = "B" }, - create "TextLabel" { Text = "C" } -} - --- this will automatically unparent text label "A", and parent labels "B" and "C" -``` diff --git a/docs/tut/crash-course/9-control-flow.md b/docs/tut/crash-course/9-control-flow.md new file mode 100644 index 0000000..afeabfc --- /dev/null +++ b/docs/tut/crash-course/9-control-flow.md @@ -0,0 +1,157 @@ +# Control Flow + +Eventually you will need a way to dynamically create and destroy UI elements +resulting from source updates. Vide provides functions to help you do this, +known as *control flow* functions. + +These functions return new sources, which hold the instances to be displayed. +These sources can be assigned as children, meaning the displayed children +will update when the input source updates. + +Control flow functions are special, because they run their components in a new +reactive scope, which can be destroyed independently of the reactive scope that +called the control flow function itself. This means that parts of your app can +be independently created then destroyed and cleaned. + +## show() + +The most basic control flow function is `show()`, which is used to conditionally +show a component. + +```lua +local source = vide.source +local show = vide.show + +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + return create "Frame" { + show(function() return not joined() end, JoinButton) + } +end +``` + +This will make a button to join if you have not joined already. + +You can also pass a third argument, a fallback to show if the condition is falsey. + +```lua +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + local function LeaveButton() + return Button { + Activated = function() joined(false) end + } + end + + return create "Frame" { + show(joined, LeaveButton, JoinButton) + } +end +``` + +## switch() + +Similar to `show()`, `switch()`, also condtionally displays one instance at a +time. It is more flexible since it can show one of many components, based on a +table used to map a source value to a component. + +```lua +local source = vide.source +local switch = vide.switch + +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + local function LeaveButton() + return Button { + Activated = function() joined(false) end + } + end + + return create "Frame" { + switch(joined) { + [true] = LeaveButton, + [false] = JoinButton + } + } +end +``` + +This example is equivalent to the previous one. + +The switch can map any value to any component. + +```lua +type ActiveMenu = "none" | "inventory" | "shop" | "settings" + +local menu = source "none" + +switch(menu) { + inventory = InventoryMenu, + shop = ShopMenu. + settings = SettingsMenu +} +``` + +## indexes() + +Often, you will have a table of values that will be displayed in a similar +manner. Rather than manually looping over each value to generate a corresponding +UI element, `indexes()` allows you to create an instance for each table index, +to display the value at that index. + +```lua +local todoList = { + "finish the crash course", + "star vide's GitHub" +} + +local function TodoList(props: { list: () -> Array }) + return create "Frame" { + create "UIListLayout" {}, + + indexes(todoList, function(todo, i) + return create "TextLabel" { + Text = function() + return i .. ": " .. todo() + end, + + LayoutOrder = i + } + end) + } +end + +TodoList { list = todoList } +``` + +For each unique index in the passed table, the transform function will be called +with 1. a source containing the value of the index, 2. the index itself. + +When the value at an index is changed, the function is not reran. Instead, the +given source for that index is updated. + +An element is only destroyed if the value of an index is set to `nil`. + +Together, these control flow functions cover the majority of cases where you +need to dynamically create and destroy parts of your UI. diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md deleted file mode 100644 index 694d90b..0000000 --- a/docs/tut/crash-course/9-derived-source.md +++ /dev/null @@ -1,93 +0,0 @@ -# Derived Sources - -We have seen the basic way to derive a source: - -```luau -local count = source(0) - -local text = function() - return "count: " .. tostring(count()) -end - -print(text()) -- "count: 0" -count(1) -print(text()) -- "count: 1" -``` - -However, in some cases where this source could be used by multiple effects at -the same time, the function wrapping the source will needlessly rerun to convert -the count into a string for each effect using it. - -```luau -local source = vide.source -local effect = vide.effect - -local count = source(0) - -local text = function() - print "ran" - return "count: " .. tostring(count()) -end - -effect(function() text() end) -effect(function() text() end) - -count(1) -- prints "ran" x2 -``` - -To avoid this, you can use `derive()` to derive a new source instead. This will -run a function in a reactive scope only when a source used inside updated. -Reading this derived source multiple times will just return a cached result. - -```luau -local source = vide.source -local effect = vide.effect -local derive = vide.derive - -local count = source(0) - -local text = derive(function() - print "ran" - return "count: " .. tostring(count()) -end) - -effect(function() text() end) -effect(function() text() end) - -count(1) -- prints "ran" x1 -``` - -Because `derive()` creates a reactive scope, it must be called within a stable -scope, just like `effect()`. - -If the recalculated value is the same as the old value, the derived source will -not rerun the effects using it. - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#111720", - "primaryTextColor": "#fff", - "primaryBorderColor": "#111720", - "lineColor": "#79B8FF", - "tertiaryColor": "#0d131b", - "tertiaryBorderColor": "#0d131b" - } -}}%% - -graph - -subgraph root - direction LR - count --> text --> effect1 & effect2 -end -``` - -Deriving a source in this manner is similar to creating an effect to update -another source. You should avoid doing this using an effect however. -Improper usage could accidently create infinite loops in the reactive graph. -Always favour deriving when you need one source to update based on another -source. diff --git a/init.luau b/init.luau deleted file mode 100644 index a9ae8e0..0000000 --- a/init.luau +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 94c8ede..0000000 --- a/pesde.toml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 4bc51cb..0000000 --- a/rokit.toml +++ /dev/null @@ -1,9 +0,0 @@ -# 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 8cc4987..f40bf3a 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 a = { + local t = { priority = priority or 1, callback = callback } - setmetatable(a :: any, ActionMT) + setmetatable(t :: any, ActionMT) - return table.freeze(a) + return table.freeze(t) end return function() diff --git a/src/apply.luau b/src/apply.luau index c05fedf..1cc533a 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,161 +1,161 @@ -local typeof = game and typeof or require "../test/mock".typeof :: never +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 flags = require "./flags" -local implicit_effect = require "./implicit_effect" -local _, is_action = require "./action"() -local graph = require "./graph" +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) type Node = graph.Node type Array = { V } -type ArrayOrV = {ArrayOrV} | V type Map = { [K]: V } -type Cache = { - -- event listeners to connect after properties are set - events: Array< - | string -- 1. event name - | () -> () -- 2. listener - >, +-- buffer of event -> callback to connect after properties are set +local event_buffer = {} :: Map ()> - -- actions to run after events are connected - actions: Map< - number, -- priority - Array<(Instance) -> ()> -- action callbacks - >, +-- buffer of priority -> callback to run after events are connected +local action_buffers = {} :: Map ()>> - -- 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 = {} - } +-- lazily create buffers on nil index +setmetatable(action_buffers :: any, { + __index = function(_, i: number) + action_buffers[i] = {} + return action_buffers[i] 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 -local function return_cache(cache: Cache ) - free_cache = cache -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 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) + 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 end - cache.nested_debug[depth][property] = true - 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 + 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 else - 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 + (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 - process_properties(value :: Map, instance, cache, depth + 1) + (value :: Instance).Parent = instance -- parent child end - elseif type(value) == "userdata" then - (value :: Instance).Parent = instance -- parent child end end - end + + -- pop next nested table off stack + depth = table.remove(nested_stack) :: number + properties = table.remove(nested_stack) :: {} + + until not properties 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 - error "attempt to call a constructor returned by create() with no properties" + throw("no properties given, did you forget to call the constructor returned by create()?") 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 + -- queue parent assignment if any for last + local parent: unknown = properties.Parent + if parent then properties.Parent = nil end - -- 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) + -- reset buffers + table.clear(event_buffer) + for _, buffer in next, action_buffers do + table.clear(buffer) end - for _, queued in actions do - for _, callback in queued do + -- 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 callback(instance) end end - local parent = caches.parent + -- finally set parent if any if parent then if type(parent) == "function" then - implicit_effect.parent(instance, parent :: () -> Instance) + bind.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 deleted file mode 100644 index e3e6d40..0000000 --- a/src/batch.luau +++ /dev/null @@ -1,23 +0,0 @@ -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 new file mode 100644 index 0000000..c614590 --- /dev/null +++ b/src/bind.luau @@ -0,0 +1,126 @@ +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 deleted file mode 100644 index 2138d06..0000000 --- a/src/branch.luau +++ /dev/null @@ -1,40 +0,0 @@ -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 063f7e9..5d439c6 100644 --- a/src/changed.luau +++ b/src/changed.luau @@ -1,5 +1,7 @@ -local action = require "./action"() -local cleanup = require "./cleanup" +if not game then script = require "test/relative-string" end + +local action = require(script.Parent.action)() +local cleanup = require(script.Parent.cleanup) local function changed(property: string, callback: (T) -> ()) return action(function(instance) @@ -10,8 +12,6 @@ local function changed(property: string, callback: (T) -> ()) cleanup(function() con:Disconnect() end) - - callback((instance :: any)[property]) end) end diff --git a/src/cleanup.luau b/src/cleanup.luau index 6c46bbf..0bfb6c1 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,43 +1,19 @@ -local typeof = game and typeof or require "../test/mock".typeof :: never +if not game then script = require "test/relative-string" end -local graph = require "./graph" +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) local get_scope = graph.get_scope -local push_cleanup = graph.push_cleanup +local add_cleanup = graph.add_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 error "cannot cleanup given object" -end - -local function cleanup(value: unknown) +local function cleanup(callback: () -> ()) local scope = get_scope() if not scope then - error "cannot cleanup outside a stable or reactive scope" + throw "cannot cleanup in a non-reactive scope" end; assert(scope) - if type(value) == "function" then - push_cleanup(scope, value :: () -> ()) - else - push_cleanup(scope, helper(value)) - end + add_cleanup(scope, callback) end -type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () } -type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } - -return cleanup :: - ( (callback: () -> ()) -> () ) & - ( (thread: thread) -> () ) & - ( (instance: Destroyable) -> () ) & - ( (connection: Disconnectable) -> () ) & - ( (instance: Instance) -> () ) & - ( (connection: RBXScriptConnection) -> () ) +return cleanup diff --git a/src/context.luau b/src/context.luau deleted file mode 100644 index 2ce3eff..0000000 --- a/src/context.luau +++ /dev/null @@ -1,74 +0,0 @@ -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 120d17d..0ce889e 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,119 +1,74 @@ -local typeof = game and typeof or require "../test/mock".typeof :: never -local Instance = game and Instance or require "../test/mock".Instance :: never +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 defaults = require "./defaults" -local apply = require "./apply" -local flags = require "./flags" +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 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 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 - 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 + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in next, default do + (instance :: any)[i] = v end - - return apply(instance, properties) end - return constructor + 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 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 +local function create(class_or_instance: string|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 + return create_instance(class_or_instance) + elseif typeof(class_or_instance) == "Instance" then + return clone_instance(class_or_instance) else - constructor = function(props) - local clone = assert(class_or_instance:Clone(), "attempt to clone a non-archivable instance") - return apply(clone, props) - end + throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance)) end - - return if properties - then constructor(properties) - else constructor + return nil :: never end -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 - - properties:setindexer(types.number, types.any) - - return properties -end - -type Create = (Name|keyof|"") -> (Properties>) -> index - -return create :: Create +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 ) diff --git a/src/defaults.luau b/src/defaults.luau index 77040ff..03badd6 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,10 +1,11 @@ -local Enum = game and Enum or require "../test/mock".Enum :: never -local Color3 = game and Color3 or require "../test/mock".Color3 :: never +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 return { Part = { Material = Enum.Material.SmoothPlastic, - Size = vector.create(1, 1, 1), + Size = Vector3.new(1, 1, 1), Anchored = true }, @@ -110,4 +111,4 @@ return { BorderColor3 = Color3.new(0, 0, 0), BorderSizePixel = 0 } -} :: { [string]: { [string]: unknown} } +} diff --git a/src/derive.luau b/src/derive.luau index b941185..49094d5 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,16 +1,22 @@ -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) local create_node = graph.create_node -local push_scope_as_child_of = graph.push_scope_as_child_of -local assert_stable_scope = graph.assert_stable_scope +local set_owner = graph.set_owner +local track = graph.track +local get_owning_scope = graph.get_owning_scope local evaluate_node = graph.evaluate_node local function derive(source: () -> T): () -> T - local node = create_node(assert_stable_scope(), source, false :: any) + local owner = get_owning_scope() + local node = create_node(false :: any, source) + + set_owner(node, owner) evaluate_node(node) return function() - push_scope_as_child_of(node) + track(node) return node.cache end end diff --git a/src/effect.luau b/src/effect.luau index 5a2cd25..43b12ab 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -1,11 +1,17 @@ -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) local create_node = graph.create_node -local assert_stable_scope = graph.assert_stable_scope +local get_owning_scope = graph.get_owning_scope local evaluate_node = graph.evaluate_node +local set_owner = graph.set_owner local function effect(callback: (T) -> T, initial_value: T) - local node = create_node(assert_stable_scope(), callback, initial_value) + local owner = get_owning_scope() + 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 be48232..1b9f80e 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -4,9 +4,4 @@ end local is_O2 = inline_test() ~= "inline_test" -return { - strict = not is_O2, - defaults = true, - defer_nested_properties = true, - batch = false, -} +return { strict = not is_O2 } diff --git a/src/graph.luau b/src/graph.luau index fd22489..f911f7b 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -1,6 +1,9 @@ -local flags = require "./flags" +if not game then script = require "test/relative-string" end -export type SourceNode = { +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) + +export type StartNode = { cache: T, [number]: Node } @@ -9,81 +12,73 @@ export type Node = { cache: T, effect: ((T) -> T) | false, cleanups: { () -> () } | false, - - context: { [number]: unknown } | false, - - owned: { Node } | false, - owner: Node | false, - - parents: { SourceNode }, - [number]: Node -- children + parents: { owner: StartNode?, [number]: StartNode }, + [number]: Node } -local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack +-- reactive scope stack +local scopes = { n = 0 } :: { [number]: Node, n: number } -local function efn(err: string) - local trace = debug.traceback(err, 2) +-- runs a given callback in a context that Luau does not allow yielding in +local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do + local t = { __mode = "kv" } + setmetatable(t, t) - if string.find(err, "^effect error stacktrace") then -- if effect error is nested - trace = string.gsub(" " .. trace, "\n", function() -- indent entire error - return "\n " + check_for_yield = function(fn, ...: any) + local args = { ... } + + t.__unm = function(_) + fn(unpack(args)) + end + + local ok, err: string? = pcall(function() + local _ = -t end) + + return ok, if err == "attempt to yield across metamethod/C-call boundary" + or err == "thread is not yieldable" then "yield occured" + else err end - - trace ..= "\nsource update stacktrace:" -return trace -end - -local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) - - local thread = coroutine.create(xpcall) - --local function efn(err: string) return debug.traceback(err, 3) end - local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) - - assert(resume_ok) - - if coroutine.status(thread) ~= "dead" then - return false, debug.traceback(thread, "attempt to yield in reactive scope") - end - - return run_ok, result end local function get_scope(): Node? return scopes[scopes.n] end -local function assert_stable_scope(): Node +local function get_owning_scope(): Node local scope = get_scope() - if not scope then local caller_name = debug.info(2, "n") - return error(`cannot use {caller_name}() outside a stable or reactive scope`, 0) + return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) elseif scope.effect then - error("cannot create a new reactive scope inside another reactive scope", 0) + throw("owning scope is not stable; are you trying to derive a new source from within a side-effect?") end - return scope end -local function push_child(parent: SourceNode, child: Node) +local function add_child(parent: StartNode, child: Node) table.insert(parent, child) table.insert(child.parents, parent) end -local function push_scope(node: Node) +local function set_owner(node: Node, owner: Node) + node.parents.owner = owner + table.insert(owner, node) +end + +local function open_scope(node: Node) local n = scopes.n + 1 scopes.n = n scopes[n] = node end -local function pop_scope() +local function close_scope() local n = scopes.n scopes.n = n - 1 scopes[n] = nil end -local function push_cleanup(node: Node, cleanup: () -> ()) +local function add_cleanup(node: Node, cleanup: () -> ()) if node.cleanups then table.insert(node.cleanups, cleanup) else @@ -91,197 +86,147 @@ local function push_cleanup(node: Node, cleanup: () -> ()) end end -local function flush_cleanups(node: Node) +local function run_cleanups(node: Node) if node.cleanups then - for _, fn in node.cleanups do - local ok, err: string? = xpcall(fn, debug.traceback) - if not ok then error(`cleanup error: {err}`, 0) end + for _, fn in next, node.cleanups do + local ok, err: string? = pcall(fn) + if not ok then throw(`cleanup error: {err}`) end end - table.clear(node.cleanups) end end -local function find_and_swap_pop(t: { T }, v: T) - local i = table.find(t, v) :: number - local n = #t - t[i] = t[n] - t[n] = nil +local function remove_child(parent: StartNode, child: Node) + local idx = table.find(parent, child) + assert(idx, "child not found") + local n = #parent + parent[idx] = parent[n] + parent[n] = nil end local function unparent(node: Node) local parents = node.parents - for i, parent in parents do - find_and_swap_pop(parent, node) + for i, parent in ipairs(parents) do + remove_child(parent, node) parents[i] = nil end end local function destroy(node: Node) - if flags.strict and table.find(scopes, node) then - error("attempt to destroy an active scope", 0) - end - - flush_cleanups(node) + run_cleanups(node) unparent(node) - - if node.owner then - find_and_swap_pop(node.owner.owned :: { Node }, node) - node.owner = false + + node.effect = false + + if node.parents.owner then + remove_child(node.parents.owner, node) + node.parents.owner = nil end - if node.owned then - local owned = node.owned - while owned[1] do destroy(owned[1]) end - end -end - -local function destroy_owned(node: Node) - if node.owned then - local owned = node.owned - while owned[1] do destroy(owned[1]) end - end + while node[1] do destroy(node[1]) 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 - if table.find(scopes, node) then - error("a scope, that should rerun due to the update of a source, is already active", 0) - end + run_cleanups(node) + open_scope(node) - local initial_value = node.cache + local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) + + close_scope() + + if not ok then throw(err :: string) end + end - for i = 1, 2 do - local cur_value = node.cache + run_cleanups(node) -- todo: move in scope? + open_scope(node) - 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) + local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) + + close_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`side-effect error from source update\n{new_value}`) + end + + node.cache = new_value + + return cur_value ~= new_value -- node has changed value +end + +local function update_from(node: StartNode, n0: number) + if not node[1] then return end + + local n = n0 + + -- unparent all children and queue for eval + do + local i = 1 + local child = node[i] + while child do + unparent(child) + + n += 1 + update_queue[n] = child + + local next_child = node[i] + + -- children who have this parent as an owner will not be unparented + -- if such a child is encountered then skip it + if next_child == child then + i += 1 + next_child = node[i] end - node.cache = new_value :: T + child = next_child end - - return initial_value ~= node.cache - else - local cur_value = node.cache - - flush_cleanups(node) - destroy_owned(node) - - push_scope(node) - local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) - pop_scope() - - if not ok then - table.clear(update_queue) - update_queue.n = 0 - error(`effect error:\n{new_value}\n`, 0) - end - - node.cache = new_value - return cur_value ~= new_value end -end -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] + if not child.effect then continue end -local function flush_update_queue(from: number) - local i = from + 1 - while i <= update_queue.n do - local node = update_queue[i] - --assert(node.effect) - - if node.owner and evaluate_node(node) then - queue_children_for_update(node) - end - - update_queue[i] = false :: any - i += 1 - end - - update_queue.n = from -end - -local function update_descendants(root: SourceNode) - local n0 = update_queue.n - queue_children_for_update(root) - - if flags.batch then return end - - local i = n0 + 1 - while i <= update_queue.n do - local node = update_queue[i] - --assert(node.effect) - - -- check if node is still owned in case destroyed after queued - if node.owner and evaluate_node(node) then - queue_children_for_update(node) + if evaluate_node(child) then + update_from(child, n) end update_queue[i] = false :: any -- false instead of nil to avoid sparse - i += 1 end update_queue.n = n0 end -local function push_scope_as_child_of(node: SourceNode) +local function update(node: StartNode) + update_from(node, update_queue.n) +end + +local function track(node: StartNode) local scope = get_scope() if scope and scope.effect then -- do not track nodes with no effect - push_child(node, scope) + add_child(node, scope) end end -local function create_node(owner: false | Node, effect: false | (T) -> T, value: T): Node - local node: Node = { +local function create_node(value: T, effect: false | (T) -> T): Node + return { cache = value, effect = effect, cleanups = false, - - context = false, - - owner = owner, - owned = false, - parents = {}, } - - if owner then - if owner.owned then - table.insert(owner.owned, node) - else - owner.owned = { node } - end - end - - return node end -local function create_source_node(value: T): SourceNode +local function create_start_node(value: T): StartNode return { cache = value } end @@ -289,33 +234,21 @@ 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 { - push_scope = push_scope, - pop_scope = pop_scope, + open_scope = open_scope, + close_scope = close_scope, evaluate_node = evaluate_node, get_scope = get_scope, - assert_stable_scope = assert_stable_scope, - push_cleanup = push_cleanup, + get_owning_scope = get_owning_scope, + add_cleanup = add_cleanup, + set_owner = set_owner, destroy = destroy, - flush_cleanups = flush_cleanups, - push_scope_as_child_of = push_scope_as_child_of, - update_descendants = update_descendants, - push_child = push_child, + run_cleanups = run_cleanups, + track = track, + update = update, + add_child = add_child, create_node = create_node, - create_source_node = create_source_node, + create_start_node = create_start_node, get_children = get_children, - flush_update_queue = flush_update_queue, - get_update_queue_length = get_update_queue_length, - set_context = set_context, - scopes = scopes, - - q = update_queue + scopes = scopes } diff --git a/src/implicit_effect.luau b/src/implicit_effect.luau deleted file mode 100644 index 6fed9bc..0000000 --- a/src/implicit_effect.luau +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index fe8416e..0000000 --- a/src/indexes.luau +++ /dev/null @@ -1,121 +0,0 @@ -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 c0fbe0e..62b6281 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,12 +1,112 @@ -assert(game, "when using vide outside of Roblox, require lib.luau instead") +-------------------------------------------------------------------------------- +-- vide.luau +-- v0.1.0 +-------------------------------------------------------------------------------- -local vide = require("@self/lib") +if not game then script = require "test/relative-string" 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 +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 +}) return vide diff --git a/src/lib.luau b/src/lib.luau deleted file mode 100644 index a8a3885..0000000 --- a/src/lib.luau +++ /dev/null @@ -1,122 +0,0 @@ -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 new file mode 100644 index 0000000..1649039 --- /dev/null +++ b/src/maps.luau @@ -0,0 +1,226 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) +local graph = require(script.Parent.graph) +type Node = graph.Node +type StartNode = graph.StartNode +local create_node = graph.create_node +local create_start_node = graph.create_start_node +local set_owner = graph.set_owner +local track = graph.track +local update = graph.update +local get_owning_scope = graph.get_owning_scope +local open_scope = graph.open_scope +local close_scope = graph.close_scope +local evaluate_node = graph.evaluate_node +local destroy = graph.destroy + +type Map = { [K]: V } + +local function check_primitives(t: {}) + if not flags.strict then return end + + for _, v in next, t do + if type(v) == "table" or type(v) == "userdata" then continue end + throw("table source map cannot return primitives") + end +end + +local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } + local owner = get_owning_scope() + + local subowner = create_node(false, false) + set_owner(subowner, owner) + + local input_cache = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local remove_queue = {} :: { K } + local scopes = {} :: Map> + + local function update_children(data) + -- queue removed values + for i in next, input_cache do + if data[i] == nil then + table.insert(remove_queue, i) + end + end + + -- remove queued values + for _, i in next, remove_queue do + destroy(scopes[i]) + + input_cache[i] = nil + output_cache[i] = nil + input_nodes[i] = nil + scopes[i] = nil + end + + table.clear(remove_queue) + + open_scope(subowner) + + -- process new or changed values + for i, v in next, data do + local cv = input_cache[i] + + if cv ~= v then + if cv == nil then -- create new scope and run transform + local scope = create_node(false, false) + scopes[i] = scope :: Node + + local node = create_start_node(v) + + set_owner(scope, subowner) + open_scope(scope) + + local ok, result = pcall(transform, function() + track(node) + return node.cache + end, i) + + close_scope() + + if not ok then + close_scope() -- subowner scope + error(result, 0) + end + + input_nodes[i] = node + output_cache[i] = result + else -- update source + input_nodes[i].cache = v + update(input_nodes[i]) + end + + input_cache[i] = v + end + end + + close_scope() + + local output_array = table.create(#scopes) + for _, v in next, output_cache do + table.insert(output_array, v) + end + check_primitives(output_array) + + return output_array + end + + local node = create_node(false :: any, function() + return update_children(input()) + end) + + evaluate_node(node) + + return function() + track(node) + return node.cache + end +end + +local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } + local owner = get_owning_scope() + + local subowner = create_node(false, false) + set_owner(subowner, owner) + + local cur_input_cache_up = {} :: Map + local new_input_cache_up = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local scopes = {} :: Map> + + local function update_children(data: Map) + local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up + + if flags.strict then + local cache = {} + for _, v in next, data do + if cache[v] ~= nil then + throw "duplicate table value detected" + end + cache[v] = true + end + end + + open_scope(subowner) + + -- process data + for i, v in next, data do + new_input_cache[v] = i + + local cv = cur_input_cache[v] + + if cv == nil then -- create new scope and run transform + local scope = create_node(false, false) + scopes[v] = scope :: Node + + local node = create_start_node(i) + + set_owner(scope, subowner) + open_scope(scope) + + local ok, result = pcall(transform, v, function() + track(node) + return node.cache + end) + + close_scope() + + if not ok then + close_scope() -- subowner scope + error(result, 0) + end + + input_nodes[v] = node + output_cache[v] = result + else -- update source + if cv ~= i then + input_nodes[v].cache = i + update(input_nodes[v]) + end + + cur_input_cache[v] = nil + end + end + + close_scope() + + -- remove old values + for v in next, cur_input_cache do + destroy(scopes[v]) + + output_cache[v] = nil + input_nodes[v] = nil + scopes[v] = nil + end + + -- update buffer cache + table.clear(cur_input_cache) + cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache + + local output_array = table.create(#scopes) + for _, v in next, output_cache do + table.insert(output_array, v) + end + check_primitives(output_array) + + return output_array + end + + local node = create_node(false :: any, function() + return update_children(input()) + end) + + evaluate_node(node) + + return function() + track(node) + return node.cache + end +end + +return function() return indexes, values end diff --git a/src/memoize.luau b/src/memoize.luau new file mode 100644 index 0000000..cf83427 --- /dev/null +++ b/src/memoize.luau @@ -0,0 +1,17 @@ +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 567d3d9..315925e 100644 --- a/src/mount.luau +++ b/src/mount.luau @@ -1,10 +1,13 @@ -local root = require "./root" -local apply = require "./apply" +if not game then script = require "test/relative-string" end + +local root = require(script.Parent.root) +local apply = require(script.Parent.apply) local function mount(component: () -> T, target: Instance?): () -> () - return root(function() + return root(function(destroy) local result = component() if target then apply(target, { result }) end + return destroy end) end diff --git a/src/read.luau b/src/read.luau deleted file mode 100644 index 3764315..0000000 --- a/src/read.luau +++ /dev/null @@ -1,5 +0,0 @@ -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 bc9c2fc..50a1c1c 100644 --- a/src/root.luau +++ b/src/root.luau @@ -1,35 +1,38 @@ -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope +local open_scope = graph.open_scope +local close_scope = graph.close_scope local destroy = graph.destroy local refs = {} -local function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) - local node = create_node(false, false, false) +local function root(fn: (destroy: () -> ()) -> T...): T... + local node = create_node(false, false) refs[node] = true -- prevent gc of root node local destroy = function() - if not refs[node] then error "root already destroyed" end + if not refs[node] then throw "root already destroyed" end refs[node] = nil destroy(node) end - push_scope(node) + open_scope(node) - local result = { xpcall(fn, debug.traceback, destroy) } + local result = { pcall(fn, destroy) } - pop_scope() + close_scope() if not result[1] then - destroy() - error(`error while running root():\n\n{result[2]}`, 0) + refs[node] = nil + throw(`mount error\n{result[2]}`) end - return destroy, unpack(result :: any, 2) + return unpack(result :: any, 2) end -return root :: (fn: (destroy: () -> ()) -> T...) -> (() -> (), T...) +return root :: ((fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ()) diff --git a/src/show.luau b/src/show.luau index 27b9d45..1cf60ca 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,36 +1,18 @@ -local source = require "./source" -local derive = require "./derive" -local effect = require "./effect" -local untrack = require "./untrack" -local switch = require "./switch" +if not game then script = require "test/relative-string" end -type Array = { T } -type Source = () -> T +local switch = require(script.Parent.switch) -local function show( - input: Source, - component: (Source, Source) -> (Obj, ...number), - fallback: ((Source) -> (Obj, ...number))? -): Source> - local filtered_input = source() +local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? + local function truthy() + return not not source() + end - 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 + return switch(truthy) { + [true] = component, + [false] = fallback, } end -return show +return show :: + ((source: () -> any, component: () -> T) -> () -> T?) & + ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) diff --git a/src/source.luau b/src/source.luau index 1da515a..f1307fc 100644 --- a/src/source.luau +++ b/src/source.luau @@ -1,17 +1,19 @@ -local graph = require "./graph" -type Node = graph.Node -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 +if not game then script = require "test/relative-string" end -export type Source = (() -> T) & ((value: T) -> T) +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_start_node = graph.create_start_node +local track = graph.track +local update = graph.update + +export type Source = (() -> T) & ((T) -> T) local function source(initial_value: T): Source - local node = create_source_node(initial_value) + local node = create_start_node(initial_value) - local function update_source(...): T + return function(...): T if select("#", ...) == 0 then -- no args were given - push_scope_as_child_of(node) + track(node) return node.cache end @@ -21,11 +23,9 @@ local function source(initial_value: T): Source end node.cache = v - update_descendants(node) + update(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 ef7decf..66dc896 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,84 +1,104 @@ -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 +if not game then script = require "test/relative-string" end +local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never --[[ + +Supported datatypes: +- number +- CFrame +- Color3 +- UDim +- UDim2 +- Vector2 +- Vector3 +- Rect + Unsupported datatypes: - bool - Vector2int16 - Vector3int16 - EnumItem + ]] -type SpringState = { +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 = { k: number, -- spring constant c: number, -- damping coeff - 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 + -- dimensions 1-3 + x0_123: Vec3, + x1_123: Vec3, + v_123: Vec3, + + -- dimensions 4-6 + x0_456: Vec3, + x1_456: Vec3, + v_456: Vec3, source_value: T -- current value of spring input source } -type SpringSettings = ({ - position: T?, - velocity: T?, - impulse: T? -}) -> () - -type TypeToVec6 = (T) -> (vector, vector) -type Vec6ToType = (vector, vector) -> T +type TypeToVec6 = (T) -> (Vec3, Vec3) +type Vec6ToType = (Vec3, Vec3) -> T local type_to_vec6 = { number = function(v) - return vector.create(v, 0, 0), vector.zero + return Vec3(v, 0, 0), ZERO end :: TypeToVec6, CFrame = function(v) - return v.Position, vector.create(v:ToEulerAnglesXYZ()) + return v.Position, Vec3(v:ToEulerAnglesXYZ()) end :: TypeToVec6, Color3 = function(v) -- todo: hsv, oklab? - return vector.create(v.R, v.G, v.B), vector.zero + return Vec3(v.R, v.G, v.B), ZERO end :: TypeToVec6, UDim = function(v) - return vector.create(v.Scale, v.Offset, 0), vector.zero + return Vec3(v.Scale, v.Offset, 0), ZERO end :: TypeToVec6, UDim2 = function(v) - return vector.create(v.X.Scale, v.X.Offset, v.Y.Scale), vector.create(v.Y.Offset, 0, 0) + return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) end :: TypeToVec6, Vector2 = function(v) - return vector.create(v.X, v.Y, 0), vector.zero + return Vec3(v.X, v.Y, 0), ZERO end :: TypeToVec6, Vector3 = function(v) - return v, vector.zero + return v, ZERO end :: TypeToVec6, Rect = function(v) - 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 }> + return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) + end :: TypeToVec6 } local vec6_to_type = { @@ -91,15 +111,15 @@ local vec6_to_type = { end :: Vec6ToType, Color3 = function(v) - return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1)) + return Color3.new(v.X, v.Y, v.Z) end :: Vec6ToType, UDim = function(v) - return UDim.new(v.X, math.round(v.Y)) + return UDim.new(v.X, v.Y) end :: Vec6ToType, UDim2 = function(a, b) - return UDim2.new(a.X, math.round(a.Y), a.Z, math.round(b.X)) + return UDim2.new(a.X, a.Y, a.Z, b.X) end :: Vec6ToType, Vector2 = function(v) @@ -112,16 +132,12 @@ local vec6_to_type = { Rect = function(a, b) return Rect.new(a.X, a.Y, a.Z, b.X) - end :: Vec6ToType, - - table = function(a, b) - return { a.X, a.Y, a.Z, b.X } - end :: Vec6ToType<{ number }> + end :: Vec6ToType } local invalid_type = { __index = function(_, t: string) - error(`cannot spring type {t}`, 0) + throw(`cannot spring type {t}`) end } @@ -130,133 +146,80 @@ 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: { [SpringState]: SourceNode } = {} -setmetatable(springs :: any, { __mode = "v" }) +local springs: { [SpringData]: StartNode } = {} +setmetatable(springs, { __mode = "v" }) -local function spring(source: () -> T, period: number?, damping_ratio: number?): (() -> T, SpringSettings) - local owner = assert_stable_scope() +local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T + local owner = get_owning_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 other than reducing step size? - -- todo: this does not catch all solver exploding cases + -- todo: is there a solution to this other than upping step frequency? if c > UPDATE_RATE*2 then -- solver will explode if this is true - error("spring damping too high, consider reducing damping or increasing period", 0) + throw("spring damping too high, consider reducing damping or increasing period") end - local data: SpringState = { + local data: SpringData = { k = k, c = c, - x0_123 = vector.zero, - x_123 = vector.zero, - x1_123 = vector.zero, - v_123 = vector.zero, + x0_123 = ZERO, + x1_123 = ZERO, + v_123 = ZERO, - x0_456 = vector.zero, - x_456 = vector.zero, - x1_456 = vector.zero, - v_456 = vector.zero, + x0_456 = ZERO, + x1_456 = ZERO, + v_456 = ZERO, source_value = false :: any, } - local output = create_source_node(false :: any) + local output = create_start_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 + springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs return value end - local updater = create_node(owner, updater_effect, false :: any) + local updater = create_node(false :: any, updater_effect) + + set_owner(updater, owner) evaluate_node(updater) -- set initial position to goal - data.x_123, data.x_456 = data.x1_123, data.x1_456 + data.x0_123, data.x0_456 = data.x1_123, data.x1_456 -- set output to goal output.cache = data.source_value - 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) - ) + return function() + track(output) + return output.cache + end end local function step_springs(dt: number) - 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 + 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 -- calculate displacement from target - local dx_123 = x_123 - x1_123 - local dx_456 = x_456 - x1_456 + local dx_123 = x0_123 - x1_123 + local dx_456 = x0_456 - x1_456 -- calculate spring force local fs_123 = dx_123*-k @@ -266,50 +229,53 @@ local function step_springs(dt: number) local ff_123 = u_123*-c local ff_456 = u_456*-c - -- calculate acceleration - local a_123 = (fs_123 + ff_123) - local a_456 = (fs_456 + ff_456) + -- calculate acceleration step + local dv_123 = (fs_123 + ff_123)*dt + local dv_456 = (fs_456 + ff_456)*dt - -- step acceleration - local v_123 = u_123 + a_123*dt - local v_456 = u_456 + a_456*dt + -- apply acceleration step + local v_123 = u_123 + dv_123 + local v_456 = u_456 + dv_456 - -- step velocity - local y_123 = x_123 + v_123*dt - local y_456 = x_456 + v_456*dt + -- calculate new position + local x_123 = x0_123 + v_123*dt + local x_456 = x0_456 + v_456*dt - s.x_123, s.x_456 = y_123, y_456 - s.v_123, s.v_456 = v_123, v_456 + data.x0_123, data.x0_456 = x_123, x_456 + data.v_123, data.v_456 = v_123, v_456 end end +local remove_queue = {} + local function update_spring_sources() - 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 + 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 - 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 + -- 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) output.cache = data.source_value else - output.cache = vec6_to_type[typeof(data.source_value)](x_123, x_456) + output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) end - update_descendants(output) + update(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 0e74932..421d583 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -1,113 +1,67 @@ -local branch = require "./branch" -local source = require "./source" -local effect = require "./effect" -local timeout = require "./timeout" () +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 -type Array = { T } type Map = { [K]: V } -type Source = () -> T -type Component = (Source) -> (T, ...number) -local function switch_map( - input: Source, - map: Map> -): Source> - local scopes = {} :: Map (), - object: Obj, - delay: number, - present: (boolean?) -> boolean, - timeout: { cancel: boolean }? - }> +local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? + local owner = get_owning_scope() - 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) - return switch_map(input, map) + local last_scope: Node? + local last_component: (() -> U)? + + local function update(cached): U? + local component = map[source()] + if component == last_component then return cached end + last_component = component + + if last_scope then + destroy(last_scope :: Node) + last_scope = nil + end + + if component == nil then return nil end + + if type(component) ~= "function" then + throw("map must map a value to a function") + end + + local new_scope = create_node(false, false) + last_scope = new_scope :: Node + + set_owner(new_scope, owner) + open_scope(new_scope) + + local ok, result = pcall(component) + + close_scope() + + if not ok then error(result, 0) end + + return result + end + + local node = create_node(nil :: any, update) + + set_owner(node, owner) + evaluate_node(node) + + return function() + track(node) + return node.cache + end end end diff --git a/src/throw.luau b/src/throw.luau new file mode 100644 index 0000000..d3ea687 --- /dev/null +++ b/src/throw.luau @@ -0,0 +1,9 @@ +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 deleted file mode 100644 index 16319e3..0000000 --- a/src/timeout.luau +++ /dev/null @@ -1,27 +0,0 @@ -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 new file mode 100644 index 0000000..04672ff --- /dev/null +++ b/src/trace.luau @@ -0,0 +1,29 @@ +-- 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 6e15578..230674d 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,25 +1,27 @@ -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) type Node = graph.Node local get_scope = graph.get_scope local function untrack(source: () -> T): T local scope = get_scope() - - if scope then - -- sources are only tracked if the node in scope has an effect - local effect = scope.effect - scope.effect = false + if not scope then + throw("cannot untrack in non-reactive scope") + end; assert(scope) - local ok, result = xpcall(source, debug.traceback) + -- sources are only tracked if the node in scope has an effect + local effect = scope.effect + scope.effect = false - scope.effect = effect :: () -> () + local ok, result = pcall(source) - if not ok then error(result, 0) end + scope.effect = effect :: () -> () - return result :: T - else - return source() - end + if not ok then error(result, 0) end + + return result end -return untrack :: ( (fn: () -> T) -> T ) & ( (fn: () -> ()) -> () ) +return untrack diff --git a/src/values.luau b/src/values.luau deleted file mode 100644 index 33bc5e4..0000000 --- a/src/values.luau +++ /dev/null @@ -1,131 +0,0 @@ -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/benchmarks.luau b/test/benchmark.luau similarity index 60% rename from test/benchmarks.luau rename to test/benchmark.luau index cbb55f9..ca9c436 100644 --- a/test/benchmarks.luau +++ b/test/benchmark.luau @@ -1,17 +1,13 @@ -local testkit = require("./testkit") +local testkit = require("test/testkit") local BENCH, START = testkit.benchmark() -local vide = require "../../vide" +local vide = require "src/init" 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) @@ -20,18 +16,18 @@ local function TITLE(name: string) print(testkit.color.white(name)) end -local function ROOT_BENCH(name: string, fn: () -> ()) +local N = 2^18 -- 262144 + +local function WRAP_BENCH(name: string, fn: () -> ()) vide.root(function(destroy) BENCH(name, fn) return destroy end)() end -local N = 2^20 - TITLE "sources" -BENCH("create source", function() +WRAP_BENCH("create source", function() local cache = table.create(N) for i = 1, START(N) do @@ -39,7 +35,7 @@ BENCH("create source", function() end end) -BENCH("get value", function() +WRAP_BENCH("get value", function() local src = source(1) for i = 1, START(N) do @@ -47,7 +43,7 @@ BENCH("get value", function() end end) -BENCH("set value", function() +WRAP_BENCH("set value", function() local src = source(1) for i = 1, START(N) do @@ -55,7 +51,7 @@ BENCH("set value", function() end end) -ROOT_BENCH("derive 1 source", function() +WRAP_BENCH("derive 1 source", function() local cache = table.create(N) local src = source(1) @@ -66,7 +62,7 @@ ROOT_BENCH("derive 1 source", function() end end) -ROOT_BENCH("derive 4 sources", function() +WRAP_BENCH("derive 4 sources", function() local cache = table.create(N) local src = vide.source(1) local src2 = vide.source(2) @@ -82,7 +78,7 @@ end) TITLE "graphs" -ROOT_BENCH("update 1->1 graph", function() +WRAP_BENCH("update 1->1 graph", function() local src = source(1) local _derived = derive(function() return src() end) @@ -92,7 +88,7 @@ ROOT_BENCH("update 1->1 graph", function() end end) -ROOT_BENCH("update 1->1 graph with cleanup", function() +WRAP_BENCH("update 1->1 graph with cleanup", function() local src = source(1) derive(function() @@ -105,7 +101,7 @@ ROOT_BENCH("update 1->1 graph with cleanup", function() end end) -ROOT_BENCH("update 1->1000 graph", function() +WRAP_BENCH("update 1->1000 graph", function() local src = source(-1) for i = 1, 1000 do @@ -119,7 +115,7 @@ ROOT_BENCH("update 1->1000 graph", function() end end) -ROOT_BENCH("update 1->1->1->1...1000 graph", function() +WRAP_BENCH("update 1->1->1->1...1000 graph", function() local src = source(-1) local last = src @@ -135,28 +131,8 @@ ROOT_BENCH("update 1->1->1->1...1000 graph", function() end end) --- 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() +-- todo: repeat with batching +WRAP_BENCH("update 1000->1 graph", function() local srcs = {} for i = 1, 1000 do srcs[i] = source(0) @@ -170,16 +146,14 @@ ROOT_BENCH("update 1000->1 graph (batched)", function() end) for i = 1, START(1) do - batch(function() - for idx = 1, 1000 do - srcs[idx](i) - end - end) + for idx = 1, 1000 do + srcs[idx](i) + end end end) --- todo: optimize this case -ROOT_BENCH("update 1000 1->1 common extern. graph", function() +-- todo: optimize, repeat with batching +WRAP_BENCH("update 1000x 1->1 common extern. graph", function() local ext = source(-1) local srcs = {} @@ -199,8 +173,8 @@ end) TITLE "property apply" -ROOT_BENCH("apply 0 properties", function() - local apply = require "../src/apply" +WRAP_BENCH("apply 0 properties", function() + local apply = require "src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -208,8 +182,8 @@ ROOT_BENCH("apply 0 properties", function() end end) -ROOT_BENCH("apply 8 properties", function() - local apply = require "../src/apply" +WRAP_BENCH("apply 8 properties", function() + local apply = require "src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -226,8 +200,8 @@ ROOT_BENCH("apply 8 properties", function() end end) -ROOT_BENCH("bind property", function() - local apply = require "../src/apply" +WRAP_BENCH("bind property", function() + local apply = require "src/apply" local instance = create("Frame") {} local src = source(1) @@ -241,8 +215,8 @@ ROOT_BENCH("bind property", function() return nil end) -ROOT_BENCH("update binding", function() - local apply = require "../src/apply" +WRAP_BENCH("update binding", function() + local apply = require "src/apply" local instance = create("Frame") {} local src = source(1) @@ -258,29 +232,11 @@ ROOT_BENCH("update binding", function() return nil end) -TITLE "switch()" - -ROOT_BENCH("switch()", function() - local M = 2^8 - - local map = {} - for i = 1, M do - map[i] = function() return i end - end - - local input = source(0) - vide.switch(input)(map) - - for i = 1, START(N) do - input(bit32.band(i, M - 1) + 1) -- i % m + 1 - end -end) - TITLE "indexes()" N /= 1024 -ROOT_BENCH("indexes() all new", function() +WRAP_BENCH("indexes() all new", function() local data = {} for i = 1, N do @@ -298,7 +254,7 @@ ROOT_BENCH("indexes() all new", function() return nil end) -ROOT_BENCH("indexes() no change", function() +WRAP_BENCH("indexes() no change", function() local data = {} for i = 1, N do @@ -318,7 +274,7 @@ ROOT_BENCH("indexes() no change", function() return nil end) -ROOT_BENCH("indexes() all change", function() +WRAP_BENCH("indexes() all change", function() local data = {} for i = 1, N do @@ -342,7 +298,7 @@ ROOT_BENCH("indexes() all change", function() src(data) end) -ROOT_BENCH("indexes() all remove", function() +WRAP_BENCH("indexes() all remove", function() local data = {} for i = 1, N do @@ -366,7 +322,7 @@ end) TITLE "values()" -ROOT_BENCH("values() all new", function() +WRAP_BENCH("values() all new", function() local data = {} for i = 1, N do @@ -384,7 +340,7 @@ ROOT_BENCH("values() all new", function() return nil end) -ROOT_BENCH("values() no change", function() +WRAP_BENCH("values() no change", function() local data = {} for i = 1, N do @@ -404,7 +360,7 @@ ROOT_BENCH("values() no change", function() src(data) end) -ROOT_BENCH("values() all change", function() +WRAP_BENCH("values() all change", function() local data = {} for i = 1, N do @@ -429,7 +385,7 @@ ROOT_BENCH("values() all change", function() src(data) end) -ROOT_BENCH("values() all remove", function() +WRAP_BENCH("values() all remove", function() local data = {} for i = 1, N do @@ -449,99 +405,106 @@ ROOT_BENCH("values() all remove", function() src(data) end) -TITLE "context()" +N *= 1024 -ROOT_BENCH("set context", function() - local ctx = context() +TITLE "cleanup" + +WRAP_BENCH("register new cleanup", function() + local cleanup = cleanup + + local cleaner = function() end + + local callers = {} + + for i = 1, N do + callers[i] = function(fn, v) + fn(v) + return i -- return unique upvalue to ensure unique closure + end + end for i = 1, START(N) do - ctx(i, function() end) + callers[i](cleanup, cleaner) end end) -ROOT_BENCH("get context (depth=1)", function() - local ctx = context() +TITLE "aggregate" + +do + -- the purpose of the two following benchmarks is to measure the overhead of + -- aggregate construction + WRAP_BENCH("set explicit mock vector2", function() + local apply = require "src/apply" + local Vector2 = require "test/mock".Vector2 + + local label = create "TextLabel" { + AnchorPoint = Vector2.new(1, 1) + } - local function run() for i = 1, START(N) do - ctx() + apply(label, { + AnchorPoint = Vector2.new(i, i) + }) end - end - - ctx(1, function() - run() end) -end) -local depth = 10 -ROOT_BENCH(`get context (depth={depth})`, function() + WRAP_BENCH("set aggregate mock vector2", function() + local apply = require "src/apply" + local Vector2 = require "test/mock".Vector2 - local ctx = context() + local label = create "TextLabel" { + AnchorPoint = Vector2.new(1, 1) + } - local function run() for i = 1, START(N) do - ctx() + apply(label, { + AnchorPoint = { i, i } + }) 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 -TITLE "spring()" +-- innacurate due to no Vector3 in vanilla Luau +-- mock vector is 200x slower than native vector -ROOT_BENCH("spring update", function() - local root, source, spring = vide.root, vide.source, vide.spring +-- WRAP_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) -ROOT_BENCH("spring step", function() - local root, source, spring = vide.root, vide.source, vide.spring +-- N /= 1024 - local src = source(0) +-- WRAP_BENCH("spring step", function() +-- local root, source, spring = vide.root, vide.source, vide.spring - root(function() - for i = 1, N do - spring(src) - end +-- local src = source(0) - src(1) +-- root(function() +-- for i = 1, N do +-- spring(src) +-- end - START(N) +-- src(1) - vide.step(1/60) +-- START(N) - return nil - end) -end) +-- vide.step(1/60) + +-- return nil +-- end) +-- end) return nil diff --git a/test/create-type-test.luau b/test/create-type-test.luau deleted file mode 100644 index b30ffd5..0000000 --- a/test/create-type-test.luau +++ /dev/null @@ -1,27 +0,0 @@ -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 a1f22a6..1dfb9bc 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -86,9 +86,6 @@ 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) @@ -97,19 +94,6 @@ 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" @@ -117,16 +101,16 @@ local Instance = {} :: any do local methods = {} - __index = function(userdata: userdata, property: string): () + local function __index(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 and get_proxy(data.parent)) + elseif property == "Parent" then data.parent elseif property == "Destroying" then data.destroying else data.properties[property] end - __newindex = function(userdata: userdata, property: string, value: unknown) + local function __newindex(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 @@ -151,6 +135,19 @@ 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", @@ -260,6 +257,40 @@ 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)) @@ -299,6 +330,7 @@ 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 new file mode 100644 index 0000000..232215c --- /dev/null +++ b/test/relative-string.luau @@ -0,0 +1,9 @@ +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 1385eda..2e3bc9b 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -1,34 +1,25 @@ -local vide = require "../../vide" +local vide = require "src/init" +local testkit = require("test/testkit") -local function system(): (number) -> number - local MAX = 40 - local MIN = 10 +local program_time = os.clock() - local _, input, output = vide.root(function() - local input = vide.source(MAX) - local output = vide.spring(input, 1, .3) - return input, output - end) +local function step(): number + local FPS = 60 + local DT = 1/FPS - 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 + repeat until os.clock() - program_time >= DT + program_time += DT + return DT 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 function redraw_block(h: number) - local OFFSET = 70 + local MIN = TERMINAL_HEIGHT * MIN_ALPHA + local MAX = TERMINAL_HEIGHT * MAX_ALPHA + local OFFSET = TERMINAL_HEIGHT - MAX local BLOCK = "█" @@ -44,35 +35,37 @@ local function redraw_block(h: number) else "▁" end - 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) + 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 end -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() +vide.root(main) + diff --git a/test/stacktrace-test.luau b/test/stacktrace-test.luau deleted file mode 100644 index e24c314..0000000 --- a/test/stacktrace-test.luau +++ /dev/null @@ -1,110 +0,0 @@ -local vide = require "../" - -do - print "=============================================================" - - local a = vide.source(1) - - local cause_error = false - - local function try_error() - if cause_error then error("uh oh") end - end - - vide.root(function() - vide.effect(function() - a() - try_error() - end) - end) - - cause_error = true - - local ok, result = pcall(function() a(2) end) - print(result) - - print "=============================================================" -end - -do - print "=============================================================" - - local a = vide.source(1) - local b = vide.source(1) - local c = vide.source(1) - - local cause_error = false - - local function try_error() - if cause_error then error("uh oh") end - end - - vide.root(function() - vide.effect(function() - a() - b(vide.untrack(b) + 1) - end) - - vide.effect(function() - b() - c(vide.untrack(c) + 1) - end) - - - vide.effect(function() - c() - try_error() - end) - end) - - cause_error = true - - local ok, result = pcall(function() a(2) end) - print(result) - - print "=============================================================" -end - -do - print "=============================================================" - - local a = vide.source(1) - local b = vide.source(1) - local c = vide.source(1) - - local cause_error = false - - local function try_error() - if cause_error then error("uh oh") end - end - - vide.root(function() - vide.effect(function() - a() - vide.untrack(function() -- todo: this trace appearing twice - b(b() + 1) - return nil - end) - end) - - vide.effect(function() - b() - vide.batch(function() - c(vide.untrack(c) + 1) - end) - end) - - - vide.effect(function() - c() - try_error() - end) - end) - - cause_error = true - - local ok, result = pcall(function() a(2) end) - print(result) - - print "=============================================================" -end diff --git a/test/testkit.luau b/test/testkit.luau index 31876eb..544c7cc 100644 --- a/test/testkit.luau +++ b/test/testkit.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- testkit.luau --- v0.7.2 +-- v0.7.1 -------------------------------------------------------------------------------- local color = { @@ -323,7 +323,7 @@ end local function print2(v: unknown) type Buffer = { n: number, [number]: string } - type Cyclic = { n: number, [{}]: number } + type Cyclic = { [{}]: true } -- overkill concatenationless string buffer local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) @@ -347,19 +347,16 @@ local function print2(v: unknown) else -- is table local tabbed_indent = indent .. TAB + str.n += 1 + if cyclic[value] then - str.n += 1 - str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`) + str[str.n] = color.gray "*cyclic reference*" return else - cyclic.n += 1 - cyclic[value] = cyclic.n + cyclic[value] = true end - str.n += 3 - str[str.n - 2] = "{ " - str[str.n - 1] = color.gray(tostring(cyclic[value])) - str[str.n - 0] = "\n" + str[str.n] = "{\n" local i, v = next(value, nil) while v ~= nil do @@ -396,7 +393,7 @@ local function print2(v: unknown) end local str = { n = 0 } - local cyclic = { n = 0 } + local cyclic = {} tos(v, 0, str, cyclic) print(table.concat(str)) end @@ -458,7 +455,7 @@ return { return BENCH, START end, - print = print2, + print2 = print2, seq = shallow_eq, deq = deep_eq, diff --git a/test/tests.luau b/test/tests.luau index af6a7f4..d9600ef 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,32 +1,12 @@ -local testkit = require "./testkit" +local testkit = require("test/testkit") local TEST, CASE, CHECK, FINISH = testkit.test() -local mock = require "./mock" +local mock = require "test/mock" local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 -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" +local vide = require "src/init" +local graph = require "src/graph" type Node = graph.Node type Map = { [K] : V } @@ -55,27 +35,27 @@ vide.strict = false TEST("graph", function() local create_node = graph.create_node - 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 track = graph.track + local update = graph.update + local add_child = graph.add_child local get_scope = graph.get_scope - local push_scope = graph.push_scope - local pop_scope = graph.pop_scope + local open_scope = graph.open_scope + local close_scope = graph.close_scope local get_children = graph.get_children - local push_cleanup = graph.push_cleanup + local add_cleanup = graph.add_cleanup local destroy = graph.destroy - local function node(owner: Node?, v: T?) - return create_node(owner or false, function(x) return not x end, v or false :: any) + local function node(v: T?) + return create_node(v or false, function(x) return not x end) end - local function scope(owner: Node?) - return create_node(owner or false, false, false) + local function scope() + return create_node(false, false) end local function cleanup(fn: () -> ()) local node = assert(get_scope()) - push_cleanup(node, fn) + add_cleanup(node, fn) end do CASE "link nodes" @@ -83,63 +63,59 @@ TEST("graph", function() local b = node() local c = node() - push_scope(c) + open_scope(c) - push_scope_as_child_of(a) - push_scope_as_child_of(b) + track(a) + track(b) - pop_scope() + close_scope() CHECK(get_children(a)[1] == c) CHECK(get_children(b)[1] == c) end do CASE "rerun linked nodes" - local root = node() local a = node() - local b = node(root) - local c = node(root) + local b = node() + local c = node() local count = 0 local function effect(x) - push_scope_as_child_of(a) - push_scope_as_child_of(b) + track(a) + track(b) count += 1 return not x end c.effect = effect - push_scope(c) + open_scope(c) effect(c.cache) - pop_scope() + close_scope() CHECK(count == 1) - update_descendants(a) + update(a) CHECK(count == 2) - update_descendants(b) + update(b) CHECK(count == 3) end do CASE "diamond graph" - -- a -> b -> d - -- -> c - local root = node() - local a, b, c, d = node(), node(root), node(root), node(root) + local a, b, c, d = node(), node(), node(), node() 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 - 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() + open_scope(b); track(a); close_scope() + open_scope(c); track(a); close_scope() + open_scope(d); track(b); track(c); close_scope() - update_descendants(a) + update(a) CHECK(b_cnt == 1) CHECK(c_cnt == 1) @@ -147,18 +123,17 @@ TEST("graph", function() end do CASE "duplicate child on rerun" - local root = node() - local a, b, c = node(root), node(root), node(root) + local a, b, c = node(), node(), node() function c.effect(x) - push_scope_as_child_of(a) - push_scope_as_child_of(b) + track(a) + track(b) return not x end - push_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); pop_scope() + open_scope(c); assert(c.effect)(NIL); close_scope() - update_descendants(a) + update(a) CHECK(#get_children(a) == 1) CHECK(#get_children(b) == 1) @@ -167,13 +142,13 @@ TEST("graph", function() do CASE "case 1" -- construct graph - local items = node(nil, { "a", "b" }) - local selected = node(nil, "a") + local items = node { "a", "b" } + local selected = node "a" local root = scope() - local scope1 = scope(root) - local scope2 = scope(root) + local scope1 = scope() + local scope2 = scope() local items_updated @@ -188,36 +163,41 @@ TEST("graph", function() end) end - do push_scope(root) + do open_scope(root) clean "root" - items_updated = node(root) - push_scope_as_child_of(items_updated) -- should not + items_updated = node() + track(items_updated) -- should not - do push_scope(items_updated) - push_scope_as_child_of(items) + add_child(root, items_updated) + do open_scope(items_updated) + track(items) - do push_scope(root) - do push_scope(scope1) + do open_scope(root) + add_child(root, scope1) + do open_scope(scope1) clean "scope1" - bind1 = node(scope1) + bind1 = node() - do push_scope(bind1) + add_child(scope1, bind1) + do open_scope(bind1) clean "bind1" - push_scope_as_child_of(selected) - pop_scope() end - pop_scope() end + track(selected) + close_scope() end + close_scope() end - do push_scope(scope2) + add_child(root, scope2) + do open_scope(scope2) clean "scope2" - bind2 = node(scope2) - do push_scope(bind2) + bind2 = node() + add_child(scope2, bind2) + do open_scope(bind2) clean "bind2" - push_scope_as_child_of(selected) - pop_scope() end - pop_scope() end - pop_scope() end - pop_scope() end - pop_scope() end + track(selected) + close_scope() end + close_scope() end + close_scope() end + close_scope() end + close_scope() end -- verify graph @@ -229,34 +209,34 @@ TEST("graph", function() do local c = get_children(root) - CHECK(#c == 0) - -- CHECK(table.find(c, items_updated)) - -- CHECK(table.find(c, scope1 :: Node)) - -- CHECK(table.find(c, scope2 :: Node)) + CHECK(#c == 3) + CHECK(table.find(c, items_updated)) + CHECK(table.find(c, scope1 :: Node)) + CHECK(table.find(c, scope2 :: Node)) end do local c = get_children(selected) CHECK(#c == 2) - CHECK(table.find(c, bind1 :: any)) - CHECK(table.find(c, bind2 :: any)) + CHECK(table.find(c, bind1)) + CHECK(table.find(c, bind2)) end do local c = get_children(scope1) - CHECK(#c == 0) - --CHECK(table.find(c, bind1)) + CHECK(#c == 1) + CHECK(table.find(c, bind1)) end do local c = get_children(scope2) - CHECK(#c == 0) - --CHECK(table.find(c, bind2)) + CHECK(#c == 1) + CHECK(table.find(c, bind2)) end -- destroy - --CHECK(table.find(get_children(root), scope1 :: Node)) + CHECK(table.find(get_children(root), scope1 :: Node)) destroy(scope1) CHECK(cleaned.scope1) @@ -265,12 +245,12 @@ TEST("graph", function() bind1 = NIL bind2 = NIL gc() - CHECK(#get_children(root) == 0) + CHECK(#get_children(root) == 2) CHECK(#get_children(selected) == 1) end do CASE "nodes garbage collection" - local wref = weak { node(nil, 1) } + local wref = weak { node(1) } destroy(wref[1]) gc() CHECK(not wref[1]) @@ -297,29 +277,33 @@ TEST("graph", function() ^ depth=1 - _, _ <- attempt to update_descendants nothing + _, _ <- attempt to update nothing ^ ]] - local root = node() - local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(root) + local a, b, c, d, e, f = node(), node(), node(), node(), node(), node() function b.effect(x) - update_descendants(d) + update(d) return not x end - push_child(a, b); push_child(a, c) - push_child(d, e); push_child(d, f) + add_child(a, b); add_child(a, c) + add_child(d, e); add_child(d, f) - update_descendants(a) + update(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" @@ -349,6 +333,9 @@ TEST("mount()", function() end) TEST("root()", function() + local root = vide.root + local cleanup = vide.cleanup + local count = 0 root(function(destroy) @@ -360,6 +347,9 @@ 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) @@ -424,6 +414,12 @@ TEST("source()", wrap_root(function() end)) TEST("derive()", wrap_root(function() + local source = vide.source + local derive = vide.derive + local effect = vide.effect + local cleanup = vide.cleanup + local untrack = vide.untrack + do CASE "derive new value on source change" local a = source(1) local b = source(2) @@ -511,7 +507,7 @@ TEST("derive()", wrap_root(function() local count = 0 local a = source(0) - local destroy = mount(function() + local destroy = vide.mount(function() local _b = derive(function() cleanup(function() count += 1 @@ -528,43 +524,38 @@ TEST("derive()", wrap_root(function() CHECK(count == 2) end - -- do CASE "behavior of effect within an effect" - -- local num = source(1) + do CASE "child with parent as owner not lost" + local num = source(0) - -- local ran = table.create(100, 0) - -- local cleaned = table.create(100, 0) + local cleaned = {} - -- local destroy = vide.mount(function() - -- local owner = derive(function() - -- local i = num() + local destroy = vide.mount(function() + local owner = derive(function() + local i = num() - -- return untrack(function() - -- return derive(function() - -- ran[i] += 1 - -- cleanup(function() - -- cleaned[i] += 1 - -- end) - -- return i - -- end) - -- end) - -- end) + return untrack(function() + return derive(function() + cleanup(function() + cleaned[i] = true + end) + return i + end) + end) + end) - -- local child1 = owner() - -- num(2) - -- CHECK(cleaned[1] == 1) - -- local child2 = owner() + local child1 = owner() + num(1) + local child2 = owner() - -- CHECK(child1() == 1) - -- CHECK(child2() == 2) - -- end) + CHECK(child1() == 0) + CHECK(child2() == 1) + end) - -- destroy() + destroy() - -- CHECK(ran[1] == 1) - -- CHECK(ran[2] == 1) - -- CHECK(cleaned[1] == 1) - -- CHECK(cleaned[2] == 1) - -- end + CHECK(cleaned[0]) + CHECK(cleaned[1]) + end do CASE "garbage collection" -- check that `b` does not allow gc of `a` @@ -584,6 +575,10 @@ 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) @@ -636,10 +631,14 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() + local source = vide.source + local effect = vide.effect + local cleanup = vide.cleanup + do CASE "root cleanup" local count = 0 - local destroy = mount(function() + local destroy = vide.mount(function() cleanup(function() count += 1 end) @@ -690,43 +689,15 @@ TEST("cleanup()", wrap_root(function() src(3) CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) end - - do CASE "cleanup objects" - local ran = {} - - root(function(destroy) - effect(function() - cleanup { disconnect = function() ran.disconnect = true end } - cleanup { Disconnect = function() ran.Disconnect = true end } - cleanup { destroy = function() ran.destroy = true end } - cleanup { Destroy = function() ran.Destroy = true end } - destroy() - end) - end) - - CHECK(ran.disconnect) - CHECK(ran.Disconnect) - CHECK(ran.destroy) - CHECK(ran.Destroy) - end end)) TEST("create()", wrap_root(function() - 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 + local create = vide.create + local source = vide.source + local cleanup = vide.cleanup 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) @@ -750,42 +721,33 @@ TEST("create()", wrap_root(function() CHECK(text.Text == "test") end - do CASE "set nested parent" - local frame = create "Frame" {} - local text = create "TextLavel" { { Parent = frame } } - CHECK(frame:GetChildren()[1] == text) - CHECK(text.Parent == frame) + 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)) end - do CASE "nested deferred" + do CASE "nested precedence" 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 {}) @@ -817,20 +779,6 @@ 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") @@ -853,7 +801,7 @@ TEST("create()", wrap_root(function() do CASE "binding destroy" local count = 0 - local destroy = mount(function() + local destroy = vide.mount(function() local src = source(0) return create "TextLabel" { @@ -919,7 +867,7 @@ TEST("create()", wrap_root(function() end do CASE "parent bound to source" - local _, wref, destroy = root(function(destroy) + local wref, destroy = vide.root(function(destroy) local frame = create "Frame" { Name = "Parent" } local parent = source(frame :: Frame?) @@ -945,111 +893,6 @@ 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 @@ -1078,346 +921,27 @@ TEST("create()", wrap_root(function() end)) TEST("show()", wrap_root(function() - do CASE "show component" - local input = source(true) - local function one() return 1 end + -- uses switch() internally, more extensive testing of scoping not needed + local source = vide.source + local show = vide.show - local output = show(input, one) + local value = source("truey" :: unknown) + local function one() return 1 end + local function two() return 2 end - CHECK(output() == 1) - input(false) - CHECK(output() == nil) - end + local output = show(value, one, two) - do CASE "fallback component" - local input = source(true) - local function one() return 1 end - local function two() return 2 end - - local output = show(input, one, two) - - CHECK(output() == 1) - input(false) - CHECK(output() == 2) - end - - do CASE "updating truth to truthy does not rerun" - local input = source(1) - local count = 0 - - local function component() - count += 1 - return 1 - end - - local output = show(input, component) - - CHECK(count == 1) - CHECK(output() == 1) - input(2) - CHECK(count == 1) - end - - do CASE "updating source passed to component" - local input = source(1 :: number?) - local count = 0 - - show(input :: () -> number?, function(value: () -> number) - effect(function() - local v = value() - - count += 1 - - CHECK(v == count) - if v ~= count then error(count) end - end) - - return true - end) - - input(2) - CHECK(count == 2) - input(3) - CHECK(count == 3) - input(nil) - CHECK(count == 3) - end - - do CASE "special strict case" - type Weapon = { - id: string, - enchant: string? - } - - vide.strict = true - - local count = 0 - local branch = 0 - - local weapon = source(nil :: Weapon?) - - effect(function() - weapon() - end) - - effect(function() - weapon() - end) - - show(weapon, function(weapon: () -> Weapon) - local enchant = function() return weapon().enchant end - - show(enchant, function(enchant: () -> string) - effect(function() - local e = enchant() - count += 1 - CHECK(e ~= nil) - if branch == 1 then - CHECK(e == "fire") - elseif branch == 2 then - CHECK(e == "poison") - end - end) - - return {} - end) - - return {} - end) - - effect(function() - weapon() - end) - - effect(function() - weapon() - end) - - branch = 1 - weapon { id = "1", enchant = "fire" } - CHECK(count == 2) - - branch = 2 - weapon { id = "1", enchant = "poison" } - CHECK(count == 4) - - weapon { id = "1", enchant = nil } - CHECK(count == 4) - - branch = 1 - weapon { id = "1", enchant = "fire" } - CHECK(count == 6) - - weapon(nil) - - branch = 2 - weapon { id = "1", enchant = "poison" } - CHECK(count == 8) - - vide.strict = false - end - - do CASE "alt" -- todo: move test - local visible = source(true) - local count = source(0) - - local outer = 0 - local inner = 0 - local destroyed = 0 - - root(function() - effect(function() - visible() - outer += 1 - - untrack(function() - effect(function() - count() - - inner += 1 - - cleanup(function() - destroyed += 1 - end) - end) - end) - end) - end) - - CHECK(outer == 1) - CHECK(inner == 1) - CHECK(destroyed == 0) - - count(count() + 1) - CHECK(outer == 1) - CHECK(inner == 2) - CHECK(destroyed == 1) - - visible(false) - CHECK(outer == 2) - CHECK(inner == 3) - CHECK(destroyed == 2) - - count(count() + 1) - CHECK(outer == 2) - CHECK(inner == 4) - CHECK(destroyed == 3) - end - - do CASE "delay (destruction)" - local input = source(false) - - local obj = {} - local value_upval - local present_upval - local cleaned = false - - local output = show(input, function(value, present) - value_upval = value - present_upval = present - cleanup(function() cleaned = true end) - CHECK(present() == false) - return obj, 1 - end) - - CHECK(output() == nil) - - input(true) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - - input(false) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - step(0.5) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - step(0.5 + 0.01) - - CHECK(output() == nil) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(cleaned) - end - - do CASE "delay (reactivate before destruction)" - local input = source(false) - - local obj = {} - local value_upval - local present_upval - local cleaned = false - - local output = show(input, function(value, present) - value_upval = value - present_upval = present - cleanup(function() cleaned = true end) - CHECK(present() == false) - return obj, 1 - end) - - CHECK(output() == nil) - - input(true) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - - input(false) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - step(0.5) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - input(true) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - - step(0.5 + 0.01) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - end - - do CASE "delay (with fallback)" - local input = source(false) - - local obj = {} - local value_upval - local present_upval - local cleaned = false - - local obj_fallback = {} - local present_fallback_upval - local cleaned_fallback = false - - local output = show(input, function(value, present) - value_upval = value - present_upval = present - cleanup(function() cleaned = true end) - CHECK(present() == false) - return obj, 1 - end, function(present) - present_fallback_upval = present - cleanup(function() cleaned_fallback = true end) - CHECK(present() == false) - return obj_fallback, 1 - end) - - CHECK(output() == obj_fallback) - CHECK(value_upval == nil) - CHECK(present_upval == nil) - CHECK(not cleaned) - CHECK(present_fallback_upval() == true) - CHECK(not cleaned_fallback) - - input(true) - - CHECK(type(output() == "table") and table.find(output(), obj) and table.find(output(), obj_fallback)) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - CHECK(present_fallback_upval() == false) - CHECK(not cleaned_fallback) - - step(1 + 0.01) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - CHECK(present_fallback_upval() == false) - CHECK(cleaned_fallback) - end + CHECK(output() == 1) + value(nil) + CHECK(output() == 2) 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) @@ -1445,27 +969,28 @@ 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) @@ -1502,7 +1027,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 @@ -1519,29 +1044,15 @@ TEST("switch()", wrap_root(function() CHECK(n0 == n1) end - - do CASE "strict" - vide.strict = true - - local input = source(0) - local output = switch(input) { - [0] = function() return 0 end, - [1] = function() return 1 end, - } - - CHECK(output() == 0) - input(1) - CHECK(output() == 1) - - vide.strict = false - end - - do CASE "delay" - -- probably unneeded because show() uses switch() internally - end end)) TEST("indexes()", wrap_root(function() + local create = vide.create + local source = vide.source + local effect = vide.effect + local indexes = vide.indexes + local cleanup = vide.cleanup + do CASE "use source" local input = source { 1, 2, 3 } @@ -1559,13 +1070,9 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local _, output = root(function() - local output = indexes(input, function(v, i) - count[i] += 1 - return v - end) - - return output + local output = indexes(input, function(v, i) + count[i] += 1 + return v end) input { 1, 2, 4 } @@ -1625,12 +1132,9 @@ TEST("indexes()", wrap_root(function() do -- check that `input` allows gc of `output` local input = source {} - local destroy, output = root(function() - return indexes(input, function(v, i) - return v, i - end) + local output = indexes(input, function(v, i) + return v, i end) - destroy() local wref = weak { output } @@ -1662,7 +1166,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 } @@ -1719,99 +1223,14 @@ TEST("indexes()", wrap_root(function() CHECK(updated[3] == 2) CHECK(updated[4] == 2) end - - do CASE "strict" - vide.strict = true - - local input = source{1} - local output = indexes(input, function(v) - return { v } - end) - - CHECK(output()[1][1]() == 1) - input{2} - CHECK(output()[1][1]() == 2) - - vide.strict = false - end - - do CASE "delay" - local input = source {} - - local cleaned_counts = {} :: Map - - local output = indexes(input, function(v, i, present) - cleanup(function() - cleaned_counts[i] = (cleaned_counts[i] or 0) + 1 - end) - return { value = v, index = i, present = present }, 1 - end) - - local function mapped() - local map = {} - local objects = output() - if objects then - for _, object in objects do - map[object.index] = { value = object.value, present = object.present } - end - end - return map - end - - ------------------------------------------------------------------------ - - do - CHECK(mapped()[1] == nil) - end - - input { 1, 2 } - - do - CHECK(mapped()[1].value() == 1) - CHECK(mapped()[1].present()) - - CHECK(mapped()[2].value() == 2) - CHECK(mapped()[2].present()) - end - - input { 2 } - step(0.5) - - do - CHECK(mapped()[1].value() == 2) - CHECK(mapped()[1].present()) - - CHECK(mapped()[2].value() == 2) - CHECK(not mapped()[2].present()) - end - - input { 1, 2 } - step(0.5 + 0.01) - - do - CHECK(mapped()[1].value() == 1) - CHECK(mapped()[1].present()) - - CHECK(mapped()[2].value() == 2) - CHECK(mapped()[2].present()) - end - - input { 3 } - step(1 + 0.01) - - do - CHECK(mapped()[1].value() == 3) - CHECK(mapped()[1].present()) - - CHECK(not mapped()[2]) - - CHECK(cleaned_counts[1] == nil) - CHECK(cleaned_counts[2] == 1) - end - end end)) TEST("values()", wrap_root(function() + local create = vide.create + local source = vide.source + local values = vide.values + local cleanup = vide.cleanup + do CASE "use source" local input = source { 1, 2, 3 } @@ -1911,7 +1330,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 } @@ -1930,103 +1349,14 @@ 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) @@ -2034,7 +1364,7 @@ TEST("spring()", wrap_root(function() CHECK(sprung() == 10) value(20) CHECK(sprung() == 10) - step(1/60) + vide.step(1/60) CHECK(sprung() ~= 10) CHECK(sprung() > 10) end @@ -2100,9 +1430,9 @@ TEST("spring()", wrap_root(function() local output = spring(input) input(1) - step(0.05) + vide.step(0.05) CHECK(output() ~= input()) -- check spring is moving - step(10) -- spring finished, should be internally removed from queue + vide.step(10) -- spring finished, should be internally removed from queue CHECK(output() == input()) -- check spring is at target local count = -1 @@ -2111,43 +1441,25 @@ TEST("spring()", wrap_root(function() count += 1 end) - step(1) -- attempt to cause another spring update + vide.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 - step(0) -- process spring queue + vide.step(0) -- process spring queue CHECK(count == 1) -- check spring was rescheduled correctly end - - do CASE "spring control" - local input = source(1) - local output, control = spring(input) - - local value = input() - local count = 0 - effect(function() - value = output() - count += 1 - end) - - CHECK(count == 1) - CHECK(value == 1) - - control { impulse = 1 } - - CHECK(count == 1) - CHECK(value == 1) - - step(1/120 + 0.001) - - CHECK(count == 2) - CHECK(value > 1) - end end)) TEST("untrack()", wrap_root(function() + local root = vide.root + local source = vide.source + local derive = vide.derive + local effect = vide.effect + local cleanup = vide.cleanup + local untrack = vide.untrack + do CASE "does not register dependency" local a = source(0) local b = source(0) @@ -2201,7 +1513,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 @@ -2248,6 +1560,8 @@ 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() @@ -2274,6 +1588,9 @@ TEST("events", function() end) TEST("actions", function() + local create = vide.create + local action = vide.action + do CASE "run action" local ran = false @@ -2307,6 +1624,11 @@ 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) @@ -2315,13 +1637,13 @@ TEST("changed()", wrap_root(function() changed("Text", output) } - CHECK(output() == "a") + --CHECK(output() == "a") text.Text = "b" CHECK(output() == "b") end do CASE "connection disconnected" - local _, text, destroy = root(function(destroy) + local text, destroy = root(function(destroy) local output = source(nil) return create "TextLabel" { @@ -2340,290 +1662,11 @@ TEST("changed()", wrap_root(function() end end)) -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() + local source = vide.source + local effect = vide.effect + local read = vide.read :: any -- todo + do CASE "read primitive" CHECK(read(1) == 1) end @@ -2633,7 +1676,7 @@ TEST("read()", wrap_root(function() CHECK(read(src) == 1) end - do CASE "push_scope_as_child_of source" + do CASE "track source" local src = source(0) local count = 0 @@ -2647,316 +1690,17 @@ TEST("read()", wrap_root(function() end end)) -TEST("context()", function() - do CASE "set context" - local ctx = context() - - root(function() - ctx(1, function() - CHECK(ctx() == 1) - - effect(function() - CHECK(ctx() == 1) - end) - end) - end) - end - - do CASE "set context outside of scope" - local ctx = context() - - local ok = pcall(function() - ctx(1, function() end) - end) - - CHECK(not ok) - end - - do CASE "get default context" - local ctx = context(1) - - CHECK(ctx() == 1) - - root(function() - local v = ctx(2, function() - CHECK(ctx() == 2) - return ctx() - end) - - CHECK(v == 2) - CHECK(ctx() == 1) - end) - end - - do CASE "context cascade" - local ctx = context(1) - local ctx2 = context() - - root(function() - ctx(2, function() - ctx2(true, function() - show(function() return true end, function() - ctx(3, function() - effect(function() - CHECK(ctx() == 3) - untrack(function() - effect(function() - CHECK(ctx() == 3) - end) - CHECK(ctx2() == true) - return {} - end) - end) - CHECK(ctx() == 3) - end) - CHECK(ctx() == 2) - return {} - end) - end) - CHECK(ctx() == 2) - end) - - CHECK(ctx() == 1) - end) - end - - do CASE "nil context" - local ctx = context(nil) - - root(function() - CHECK(ctx() == nil) - ctx(nil, function() - CHECK(ctx() == nil) - ctx(true :: any, function() - CHECK(ctx() == true) - ctx(nil, function() - effect(function() - untrack(function() - effect(function() - CHECK(ctx() == nil) - end) - return {} - end) - end) - end) - end) - end) - end) - end -end) - -TEST("nested effects cases", function() - local ran = 0 - local cleaned = 0 - - local function Count() - local count = source(0) - - effect(function() - count() - ran += 1 - cleanup(function() cleaned += 1 end) - end) - - return nil - end - - local function App(destroy) - local name = source "a" - - effect(function() - name() - untrack(Count) - end) - - CHECK(ran == 1) - CHECK(cleaned == 0) - - name "b" - - CHECK(ran == 2) - CHECK(cleaned == 1) - - destroy() - - CHECK(ran == 2) - CHECK(cleaned == 2) - end - - root(App) -end) - -TEST("graph edge cases", wrap_root(function() - do CASE "diamond A,B,C,D" - --[[ - - a > b > d - > c > - - ]] - - local a = source(0) - - local b = derive(function() return (a() % 2 == 0) and 1 or 0 end) - local c = derive(function() return a() * 2 end) - local d = derive(function() return b() + c() end) - - local count = { b = 0, c = 0, d = 0 } - effect(function() b(); count.b += 1 end) - effect(function() c(); count.c += 1 end) - effect(function() d(); count.d += 1 end) - - a(1) - CHECK(count.b == 2) - CHECK(count.c == 2) - CHECK(count.d == 2) - CHECK(d() == 2) - - a(3) - CHECK(count.b == 2) - CHECK(count.c == 3) - CHECK(count.d == 3) - CHECK(d() == 6) - end - - do CASE "diamond A,B,C,D,E" - --[[ - - a > b > e - > c > d > - - ]] - - local a = source(0) - - local b = derive(function() return (a() % 2 == 0) and 1 or 0 end) - local c = derive(function() return a() * 2 end) - local d = derive(function() return c() * 2 end) - local e = derive(function() return b() + d() end) - - local count = { b = 0, c = 0, d = 0, e = 0 } - effect(function() b(); count.b += 1 end) - effect(function() c(); count.c += 1 end) - effect(function() d(); count.d += 1 end) - effect(function() e(); count.e += 1 end) - - CHECK(e() == 1) - - a(1) - - CHECK(count.b == 2) - CHECK(count.c == 2) - CHECK(count.d == 2) - CHECK(count.e == 3) -- todo: redundant re-eval - CHECK(e() == 4) - - a(3) - CHECK(count.b == 2) - CHECK(count.c == 3) - CHECK(count.d == 3) - CHECK(count.e == 4) - CHECK(e() == 12) - end - - do CASE "repeated read" - local a = source(0) - local b = derive(function() return a() + a() end) - - local count = 0 - effect(function() b(); count += 1 end) - - a(1) - CHECK(b() == 2) - CHECK(count == 2) - end - - do CASE "do not destroy children" - local parent = source(0) - - local _, - destroy, - parent_to_destroy, - update_parent_to_destroy - = root(function(destroy) - local src = source(0) - return - destroy, - derive(function() return src() end), - src - end) - - local count = 0 - - effect(function() - count += 1 - parent() - parent_to_destroy() - end) - - parent(parent() + 1) - CHECK(count == 2) - update_parent_to_destroy(1) - CHECK(count == 3) - - destroy() - - update_parent_to_destroy(2) - CHECK(count == 3) - - parent(parent() + 1) - CHECK(count == 4) - end - - do CASE "double destroy" - -- issue: - -- parent evaluates - -- child A queued - -- child B queued - -- child A destroys child B - -- child B reevaluates due to already being queued - -- parent destroys, destroys child B - uh oh - - local _, - destroy_parent, - parent, - update_parent - = root(function(destroy) - local src = source(0) - return - destroy, - derive(function() return src() end), - src - end) - - local _, destroy_child, _child_B = nil, function() end, nil - - local count_A = 0 - - -- child_A - effect(function() - count_A += 1 - parent() - destroy_child() - end) - - local count_B = 0 - destroy_child, _child_B = root(function(destroy) - return - destroy, - derive(function() count_B += 1; return parent() end) - end) - - update_parent(parent() + 1) - CHECK(count_A == 2) - CHECK(count_B == 1) -- child B should not run again - destroy_parent() -- should not error - CHECK(true) - end -end)) +vide.strict = true 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) @@ -3011,6 +1755,16 @@ 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 } @@ -3048,115 +1802,6 @@ TEST("strict", wrap_root(function() CHECK(ok) end - - do CASE "effect counter" - local src = source(true) - - local count = 0 - - effect(function(x: number) - src() - count = x + 1 - return count - end, count) - - CHECK(count == 2) - src(not src()) - CHECK(count == 4) - end - - do CASE "effect using derived source" - local input = source(true) - - local output = derive(function() - return input() - end) - - local count = 0 - - effect(function() - output() - count += 1 - end) - - CHECK(count == 2) - - input(false) - - CHECK(count == 4) - end - - do CASE "destruction of active scope" - local src = source(false) - local count = 0 - - root(function() - show(src, function() - src(false) - cleanup(function() count += 1 end) - return {} - end) - end) - - local ok = pcall(function() - src(true) - end) - - CHECK(count == 0) - CHECK(not ok) - end - - do CASE "destruction of active scope in indexes" - local src = source {} - - local count_1 = 0 - local count_2 = 0 - - root(function() - effect(function() - untrack(function() - indexes(src, function() - cleanup(function() count_1 += 1 end) - src {} - cleanup(function() count_2 += 1 end) - return {} - end) - end) - end) - end) - - local ok = pcall(function() - src { 1 } - end) - - CHECK(not ok) - end - - do CASE "destruction of active scope in values" - local src = source {} - - local count_1 = 0 - local count_2 = 0 - - root(function() - effect(function() - untrack(function() - values(src, function() - cleanup(function() count_1 += 1 end) - src {} - cleanup(function() count_2 += 1 end) - return {} - end) - end) - end) - end) - - local ok = pcall(function() - src { {} } - end) - - CHECK(not ok) - end end)) local ok = FINISH() diff --git a/todo.md b/todo.md index 6f254bd..5b9fc47 100644 --- a/todo.md +++ b/todo.md @@ -1,4 +1,13 @@ # todo -- improve error traces -- prevent redundant re-eval of nodes in a complex diamond graph +- property binding optimization + - would no longer allow `cleanup()` usage in binding scopes +- solution to nested reactivity, see: SolidJS stores +- optimize wide graph updating +- implement from solid: + - Portal + - batch +- optimize `indexes()` double-diffing +- define behavior of deriving a source within a derived source +- define behavior of node destruction under a root that has a child in another root +- improve crash course, some sections feel like information dumps diff --git a/wally.toml b/wally.toml index 1584e06..f118dc8 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.4.1" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] @@ -16,4 +16,4 @@ exclude = [ "CHANGELOG.md", "README.md", "todo.md" -] +] \ No newline at end of file