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..83d16e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,83 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## [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 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..0a3500d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,83 +1,71 @@ -//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" +import { withMermaid } from "vitepress-plugin-mermaid"; + +// https://vitepress.dev/reference/site-config +export default withMermaid({ + title: "Vide", + titleTemplate: ":title - A reactive UI library for Luau", + description: "A reactive UI library for Luau.", + base: "/vide/", + head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], + + themeConfig: { + logo: "/logo.svg", + + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: "Home", link: "/" }, + { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, + { text: "API", link: "/api/reactivity-core"}, + ], + + sidebar: { + "/api/": [ + { + text: "API", + items: [ + { text: "Reactivity: Core", link: "/api/reactivity-core" }, + { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, + { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, + { text: "Element Creation", link: "/api/creation" }, + { text: "Animation", link: "/api/animation" }, + { text: "Strict Mode", link: "/api/strict-mode" }, + ] + } + ], + + "/tut/": [ + { + text: "Crash Course", + items: [ + { text: "Introduction", link: "/tut/crash-course/1-introduction" }, + { text: "Element Creation", link: "/tut/crash-course/2-creation" }, + { text: "Components", link: "/tut/crash-course/3-components" }, + { text: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Root Scopes", link: "/tut/crash-course/6-root" }, + { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, + { text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, + { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, + { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, + { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, + { text: "Actions", link: "/tut/crash-course/13-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/15-concepts" } + ] + }, + { + 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..8e67478 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -1,4 +1,39 @@ -# Element Creation +# Element Creation API + +
+ +## mount() + +Runs a function in a new reactive scope and optionally applies its result to a +target instance. + +- **Type** + + ```lua + function mount(component: () -> T, target: Instance?): () -> () + ``` + +- **Details** + + The result of the function is applied to a target in the same way + properties are using `create()`. + + The function is ran in a new reactive scope, just like + [root()](reactivity-core.md#root). + + Returns a function that when called will destroy the reactive scope. + +- **Example** + + ```lua + local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "Vide" } + } + end + + mount(App, game.StarterGui) + ``` ## create() @@ -6,11 +41,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** @@ -41,53 +76,73 @@ Creates a new UI element, applying any given properties. 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]) + local con - instance:GetPropertyChangedSignal(property):Connect(function() + callback(instance[property]) end) - -- disconnect on scope destruction to allow gc of instance + -- disconnect on reactive scope destruction to allow gc of instance cleanup(function() - cn:Disconnect() + con:Disconnect() end) end) end @@ -95,7 +150,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) } ``` @@ -106,47 +161,16 @@ A wrapper for `action()` to listen for property changes. - **Type** - ```luau - function changed(property: string, fn: (unknown) -> ()): Action + ```lua + function changed(property: string, callback: (...unknown) -> ()): Action ``` - **Details** - Will run the given function immediately and whenever the property updates. + Will run the given callback any time the property is changed, as well as + when the action is initially run. - The function is called with the updated property value. + The changed connection is disconnected when the reactive scope the action is + ran in is destroyed. 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..133bc4a 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -1,155 +1,125 @@ -# Reactivity: Core +# Reactivity API: Core -## Scopes - -Vide code can run in one of two scopes: STABLE or REACTIVE. - -- Reactive scopes rerun if a source read within updates. -- Stable scopes never rerun. -- Reactive scopes cannot be created directly within another reactive scope. -- When a scope is destroyed, all scopes created within are also destroyed. - -Different functions in Vide's API will run code in different scopes. +
:::warning -Yielding is not allowed in any stable or reactive scope. Strict mode will check -for this. +Yielding is not allowed in any reactive scope. Strict mode can check for this. ::: -## root() STABLE +## root() -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. + Returns the result of the given function. + + Creates a new root reactive scope, where creation and derivations of sources + can be tracked and properly disposed of. + + A function to destroy the root is passed into the callback, which will run + any cleanups and allow derived sources created to garbage collect. ## 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. + + Sources can be created outside of reactive scopes. - **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 in a new reactive scope on source update. - **Type** - ```luau - function effect(fn: () -> ()) + ```lua + function effect(callback: () -> ()) ``` - **Details** - The function is ran once immediately. + Any time a source referenced in the callback is changed, the callback will + be reran. + + The callback is ran to initially ran on first call to find dependent sources. - **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 in a new reactive scope 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. + + The callback is ran to initially ran on first call to find dependent sources. - **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..ee82067 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -1,58 +1,53 @@ -# 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 reran or destroyed. - **Type** - ```luau - function cleanup(v: Function | Disconnectable | Destroyable | thread) + ```lua + function cleanup(callback: () -> ()) + function cleanup(obj: Destroyable) + function cleanup(obj: Disconnectable) - type Function = () -> () type Destroyable = { destroy: () -> () } type Disconnectable = { disconnect: () -> () } ``` - **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 be tracked by a 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,98 +56,39 @@ 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 ``` ## read() -Utility used to read a value that is either a primitive or a source. +Utility used to read a value that is either a primitive or a source. Sources +read can still be tracked inside a reactive scope. - **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. +Runs a given function where any source updates made within the function do not +trigger effects until after the function runs. - **Type** - ```luau + ```lua 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) - ``` + sources need to be updated. Updating those sources inside a batch call will + only cause the effect to run once after the batch call ends instead of after + each time a source is updated. +-------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 6f9ee80..958672e 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,25 @@ 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 derived sources and effects twice each time they update,it helps +ensure that derived source computations are pure, and that any +cleanups made in derived sources or effects are done correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, -which strict mode 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..e156bea 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,12 +1,14 @@ -{ - "type": "module", - "scripts": { - "docs:dev": "vitepress dev", - "docs:build": "vitepress build", - "docs:preview": "vitepress preview" - }, - "devDependencies": { - "vitepress": "^1.4.5", - "vitepress-plugin-mermaid": "2.0.17" - } -} +{ + "type": "module", + + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + + "devDependencies": { + "vitepress": "^1.0.0-rc.4", + "vitepress-plugin-mermaid": "^2.0.14" + } +} 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..20872cf --- /dev/null +++ b/docs/tut/advanced/reactive-scoping.md @@ -0,0 +1,253 @@ +# Reactive Scoping + +This is a brief document designed to give the user more insight into how Vide's +reactive system works. + +## Graph Basics + +Vide's reactivity can be represented as a graph, where each source, derived +source, and effect is a node on that graph. The term "*reactive scope*" is just +an abstraction used to refer to these nodes. Each node is a reactive scope. + +Each node stores a cached value, a side-effect function, cleanup functions, +its parents and children, and its owner and owned. + +Whenever a node is updated it will: + +1. destroy its owned nodes +2. run its cleanups +3. rerun its side-effect and update its cached value +4. if its cached value changes, update its children recursively. + +There is a difference between children nodes and owned nodes: + +- children nodes are updated when a parent is updated. +- owned nodes are destroyed when a parent is updated. +- both children and owned are destroyed when a parent is destroyed. + +Nodes created by `root()` generally have no children, and only tracks owned. +Nodes created by `derive()` generally have no owned, and only tracks children. + +## Basic Example + +```lua +root(function() + local forename = source "quan" + local surname = source "xi" + + local name = derive(function() + return forename() .. " " .. surname() + end) + + effect(function() + print("new name: " .. name()) + end) +end) +``` + +This code will produce a graph that looks like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +graph + subgraph root + forename & surname --> name + name --> effect + end +``` + +Nodes connected by arrows represent parent and children connections. +Nodes within other nodes represent owner and owned connections. + +Any time a node is updated, Vide will traverse and update that node's children, +its children's children, etc, until all nodes descending from that node has been +updated. Traversal will stop at a node if that node's cached value does not +change after an update. + +When the side-effect for a node is being reran when a node is updated, any +other nodes read within that side-effect are set as parents of the node +currently being reran. As those nodes are read, we know that the current node +depends on them, so any time those nodes are updated, they will update dependent +nodes since they will be stored as children. + +When destroying a node, its descendents are traversed and also destroyed. +When being destroyed, a node's connections (parents and children, owner and +owned) are cleared, and any pending cleanup functions are ran. + +The purpose of `root()` (which is called internally by `mount()`) is to setup +the root node which will track any node created inside its scope, or any +cleanups registered. Without it, nodes could be garbage collected without a +chance to run pending cleanups which can cause memory leakage. + +Nodes created by `source()` can actually exist outside of root nodes, since +they do not have direct side-effects or cleanups, they do not have to be +explicitly destroyed. + +## Control-flow Graph Example + +Control flow functions in Vide are special, as they can dynamically create and +destroy new root scopes. + +It is the combination of the above which allows us to write components like so: + +```lua +local function Counter(props: { text: string }) + local count = source(0) + + local connection = stepped:Connect(function() count(count() + 1) end) + + cleanup(function() connection:Disconnect() end) + + return create "TextLabel" { + Text = function() + return props.text() .. ": " .. count() + end + } +end +``` + +Vide doesn't recognise this as a "component", that is a user abstraction. Vide +just sees this as a function that creates nodes in the reactive graph. + +```lua +root(function() + local counters = { "A", "B" } + + indexes(counters, function(name) + return Counter { text = name } + end) +end) +``` + +This code produces a graph like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#fff" + } +}}%% + +graph LR + subgraph root + counters --> indexes + + subgraph root1[subroot 1] + n1[name] --> p1[prop binding] + end + + subgraph root2[subroot 2] + n2[name] --> p2[prop binding] + end + end + + indexes .-> root1 & root2 +``` + +This shows how the `indexes()` control flow function creates and manages new +root scopes. The function creates an effect seen as `indexes` in the graph, +which manages the new roots `subroot 1` and `subroot 2`, as well as the sources +`name` for which one exists for each index value in the input table. + +When the input table changes, `indexes()` can automatically destroy and create +subroots based on the changed indexes. Destroyed nodes run any cleanups made, in +this case it is the cleanups to disconnect the counters connection. The same +applies to all other control flow functions. + +Whenever the root reactive scope is destroyed, all its children, `counters` and +`indexes` will be destroyed too, which means that `indexes` children, the +subroots, will also be destroyed. Everything is nicely cleaned up. + +## Custom Control-flow Example + +Below is a simple example of the `show()` control-flow function. + +Each time `visible` changes, `show()` will destroy the current reactive scope +and rerun its function in a new one. + +```lua +local visible = source(true) +local count = source(0) + +root(function() + show(visible, function() + return create "TextLabel" { Text = count } + end) +end) +``` + +The above code produces a graph like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1B1B1F" + } +}}%% + +graph LR + subgraph root + direction LR + show + + subgraph subroot["show() subroot"] + p1[prop binding] + end + end + + visible --> show + count --> p1 + show -.- subroot +``` + +This can be recreated without the `show()` control-flow function, with the +following code: + +```lua +local visible = source(true) +local count = source(0) + +root(function() + local output = derive(function() + visible() + + -- untrack so any source read from within this scope + -- will not cause the outer `derive()` call to rerun, + -- we only want `derive()` to rerun when `visible` changes + return untrack(function() + local label = create "TextLabel" {} + + effect(function() + label.Text = count() + end) + + return label + end) + end) +end) +``` + +Both of the above code samples will produce the same visible result. diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md 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..261ce83 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -4,18 +4,22 @@ This is a tutorial that introduces the concepts and usage of Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). +This tutorial assumes familiarity with Luau and Roblox UI. + ## Why Vide? -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 complicated, slow, and tedious. -Some of Vide's main design choices: +Vide tries to simplify and speed up this process by providing a declarative and +reactive of style programming, which lets you focus more on designing the UI +itself and not having to manually update or reparent UI instances. -- Syntax minimal. -- Data oriented. -- Typechecking compatible. -- Instance independent. +Some of the main focuses behind Vide's design choices: -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. +- Minimal syntax. +- Complete typechecking +- Independence from instances. + +As with most declarative libraries, there is an initial learning curve to +understand the concepts and usage. This tutorial tries to comprehensively +cover these concepts and usage, more so than you need just to use it. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index e25d451..b9361ec 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,37 +2,83 @@ 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. +is used to queue a cleanup callback for the next time a reactive scope re-runs. -```luau -local root = vide.root +```lua +local mount = vide.mount local source = vide.source -local effect = vide.effect local cleanup = vide.cleanup -local count = source(0) +local function Timer() + local count = source(0) -local destroy = root(function() - effect(function() - local x = count() - cleanup(function() print(x) end) + local con = game:GetService("RunService").Heartbeat:Connect(function(dt) + count(count() + dt) end) - cleanup(function() print "root destroyed" end) -end) + cleanup(function() + con:Disconnect() + end) -count(1) -- prints "0" -count(2) -- prints "1" -destroy() -- prints "2" and "root destroyed" + 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 queued cleanups are ran, heartbeat connection stopped ``` -::: tip -Roblox instances do not need to be explicitly destroyed for their +In the above example, this allows us to disconnect the heartbeat connection +when the reactive scope responsible for creating the timer component is +destroyed, such as when it is unmounted. + +Vide does not see "components", it only sees reactive scopes and how they are +linked together. Components are just a user pattern that creates UI instances +alongside effects. In other words, instances are just a side-effect of the +reactive graph. When a reactive scope is created, you create a corresponding +instance to display that data, when that reactive scope is destroyed, any +cleanups queued will be ran and take care of anything that needs to be, such +as disconnecting connections. + +This is another reason why `mount()` is used at the top level of your app, so +that any registered cleanups created by your app components can be ran when +they are destroyed. + +Side note: 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. -::: + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +graph + +subgraph mount + direction LR + cleanup([cleanup]) ~~~ count + count --> bind["effect (text binding)"] +end +``` diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md new file mode 100644 index 0000000..e5430b8 --- /dev/null +++ b/docs/tut/crash-course/11-control-flow.md @@ -0,0 +1,276 @@ +# Control Flow + +Eventually you will need a way to dynamically create and destroy UI elements +resulting from source updates. Vide provides functions to help you do this, +known as *control flow* functions. + +These functions return new sources, which hold the instances to be displayed. +These sources can be assigned as children, meaning the displayed children +will update when the input source updates. + +Control flow functions are special, because they run their components in a new +reactive scope, which can be destroyed independently of the reactive scope that +called the control flow function itself. This means that parts of your app can +be independently created then destroyed. + +## show() + +The most basic control flow function is `show()`, which is used to conditionally +show a component. + +```lua +local source = vide.source +local show = vide.show + +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + return create "Frame" { + show(function() return not joined() end, JoinButton) + } +end +``` + +This will make a button to join if you have not joined already. + +You can also pass a third argument, a fallback to show if the condition is falsey. + +```lua +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + local function LeaveButton() + return Button { + Activated = function() joined(false) end + } + end + + return create "Frame" { + show(joined, LeaveButton, JoinButton) + } +end +``` + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph root["mount() scope"] + direction LR + joined --> show -.- subroot + + subgraph subroot["show() scope"] + direction LR + Button + end +end +``` + +`show()` will implicitly create an effect depending on `joined`, which can be +seen as `show` on the graph. This effect manages, and can create or destroy +a separate reactive scope seen as `show() scope` on the graph. The dotted line +indicates that it isn't actually connected, only indirectly managed through +code. + +## switch() + +Similar to `show()`, `switch()`, also condtionally displays one instance at a +time. It is more flexible since it can show one of many components, based on a +table used to map a source value to a component. + +```lua +local source = vide.source +local switch = vide.switch + +local function JoinMenu() + local joined = source(false) + + local function JoinButton() + return Button { + Activated = function() joined(true) end + } + end + + local function LeaveButton() + return Button { + Activated = function() joined(false) end + } + end + + return create "Frame" { + switch(joined) { + [true] = LeaveButton, + [false] = JoinButton + } + } +end +``` + +This example is equivalent to the previous one. + +The switch can map any value to any component. + +```lua +type ActiveMenu = "none" | "inventory" | "shop" | "settings" + +local menu = source "inventory" + +switch(menu) { + inventory = InventoryMenu, + shop = ShopMenu, + settings = SettingsMenu +} +``` + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph root["mount() scope"] + direction LR + menu --> switch -.- subroot + + subgraph subroot["switch() scope"] + direction LR + Menu + end +end +``` + +## indexes() + +Often, you will have a table of values with each value displayed in a similar +manner. Rather than manually looping over each value to generate a corresponding +UI element, `indexes()` allows you to create elements for each table index, to +display the value at that index. + +```lua +local todoList = source { + "finish the crash course", + "star vide's GitHub" +} + +local function TodoList(props: { list: () -> Array }) + return create "Frame" { + create "UIListLayout" {}, + + indexes(todoList, function(todo, i) + return create "TextLabel" { + Text = function() + return i .. ": " .. todo() + end, + + LayoutOrder = i + } + end) + } +end + +TodoList { list = todoList } +``` + +For each index in the given source table, the given function will be called +with: + +1. a source containing the value of the index +2. the index itself + +When the value at an index is changed, the function is not reran. Instead, the +given source for that index is updated. + +Any time the input source table is updated, the given function will be ran for +any newly added indexes, while any removed indexes (indexes now with a `nil` +value), will have its corresponding reactive scope destroyed to clean up that +element. + +`indexes()` is said to *map* each table index to a new UI element that can +update to display the current value at that index. + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph root ["mount() scope"] + direction LR + todoList --> indexes -.- subroot1 & subroot2 + + subgraph subroot1 ["indexes() scope 1"] + direction LR + value1[todo] --> prop1["prop binding"] + end + + subgraph subroot2 ["indexes() scope 2"] + direction LR + value2[todo] --> prop2[prop binding] + end +end +``` + +One thing to note regarding table sources, is that when you edit a table in a +source, you must set that table again to actually update the source. + +```lua +local src = source { 1, 2 } +local data = src() +table.insert(data, 3) -- no effects will run +src(data) -- effects will run +``` + +Together, these control flow functions cover the majority of cases where you +need to dynamically create and destroy parts of your UI. + +If you need to do something that these control flow functions cannot, you can +always use `mount()` within an effect to dynamically create and destroy +components on your own terms. Just remember to use `cleanup()` to unmount when +the effect reruns. diff --git a/docs/tut/crash-course/11-dynamic-scopes.md b/docs/tut/crash-course/11-dynamic-scopes.md 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/12-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md new file mode 100644 index 0000000..d8c44ce --- /dev/null +++ b/docs/tut/crash-course/12-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 = { + -- also can optionally pass a source that returns an array of children too + Children = Array | () -> Array +} + +local function List(props: Children & Layout) + return create "Frame" { + props.Children, + props.Layout, + create "UIListLayout" {} + } +end + +List { + Layout = { + Position = UDim2.new() + }, + + Children = { + create "TextLabel" { Text = "1" }, + create "TextLabel" { Text = "2" } + } +} +``` + +Deeper nested properties are guaranteed to be set after shallower nested +properties, this can be used to create overridable default properties. + +```lua +local function List(props: Children & Layout) + return create "Frame" { + props.Children, + props.Layout, + + -- can be overriden by `props.Layout` + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.fromScale(0.5, 0), + + create "UIListLayout" {} + } +end +``` diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/13-actions.md similarity index 74% rename from docs/tut/crash-course/12-actions.md rename to docs/tut/crash-course/13-actions.md index 60e5c30..06b431d 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/13-actions.md @@ -1,11 +1,13 @@ # Actions -Actions are special callbacks that you can pass along with properties, +Actions in Vide are special callbacks that you can pass along with properties, to run some code on an instance receiving them. -```luau +```lua local action = vide.action +``` +```lua create "TextLabel" { Text = "test", @@ -20,15 +22,13 @@ create "TextLabel" { Actions can be wrapped with functions for reuse. 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) -> ()) +local function changed(prop: string, callback: (new) -> ()) return action(function(instance) - local connection = instance:GetPropertyChangedSignal(property):Connect(function() + local connection = instance:GetPropertyChangedSignal(prop):Connect(function() callback(instance[property]) end) @@ -44,11 +44,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/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/13-strict-mode.md b/docs/tut/crash-course/14-strict-mode.md similarity index 91% rename from docs/tut/crash-course/13-strict-mode.md rename to docs/tut/crash-course/14-strict-mode.md index 0de8887..ca8465a 100644 --- a/docs/tut/crash-course/13-strict-mode.md +++ b/docs/tut/crash-course/14-strict-mode.md @@ -11,9 +11,9 @@ want this. Strict mode will run derived sources and effects twice each time they update. This is to help ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done properly. +cleanups made in derived sources or effects are done correctly. -```luau +```lua local source = vide.source local effect = vide.effect @@ -23,7 +23,6 @@ local count = source(0) local ran = 0 effect(function() - count() ran += 1 end) diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md new file mode 100644 index 0000000..2c2c313 --- /dev/null +++ b/docs/tut/crash-course/15-concepts.md @@ -0,0 +1,132 @@ +# 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 by the user. + +## Effect + +Anything that happens in reponse to a source update. + +Vide has built-in functions to create effects such as + +- `effect()` - runs arbitrary user code on source update +- `derive()` - updates a derived source on source update + +## Reactive Scope + +A scope created by certain Vide functions where source updates can be tracked, +and cleanups queued. + +When a source used inside a reactive scope is updated, the reactive scope will +rerun. + +Reactive scopes are created by functions such as + +- `root()` +- `effect()` +- `derive()` + +## Owner + +A reactive scope created within an outer reactive scope, is *owned* by the outer +reactive scope. + +When a reactive scope is re-ran or destroyed, all reactive scopes owned by it +are also destroyed. + +Vide does not let you create reactive scopes without owners. + +## Root Reactive Scope + +A top-level reactive scope. These scopes are an exception to the owner rule. + +Created by `root()`, which `mount()` uses internally. + +A root reactive scope can be created on its own. It allows other reactive scopes +to be created with an owner. + +Root reactive scopes must be destroyed manually by the user, a function to do +this is given by `root()`. + +A root reactive scope can be created within another reactive scope and it will +not automatically be owned by that scope. + +## Cleanup + +Cleans up the result from an effect. + +Unneeded in most cases, a cleanup is arbitrary code that can be ran before +a reactive scope is rerun or destroyed, so that the result from the previous +run can be cleaned up. A cleanup can be queued by using `cleanup()` within +a reactive scope. + +## Tracking + +Reactive scopes are tracking by default, meaning sources read from within scope +will be tracked. + +A reactive scope can be made temporarily non-tracking within `untrack()`, so +that any source used will be ignored. The only function that creates a +nontracking reactive scope by default is `root()`. + +## Reactive Graph + +The combination of 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 + +```lua +local count = source(0) + +root(function() + local text = derive(function() + return "count: " .. text() + end) + + effect(function() + print(text()) + end) +end) +``` + +### Graph resulting from code + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph LR + +subgraph root + text --> effect +end + +count --> text +``` + +Notes: + +- Since `count` is a source, not an effect, it can exist + outside of a root reactive scope. +- An update to `count` will cause `text` to rerun, which + then causes `effect` to rerun. +- When the root reactive scope is destroyed, `text` and + `effect` will be destroyed alongside it, since they are + owned by it. `count` will be untouched and future updates + to `count` will have no effect. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 668a470..c1cafc0 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -2,10 +2,13 @@ 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 create = vide.create return create "ScreenGui" { @@ -36,3 +39,11 @@ return create "ScreenGui" { Assign a value to a string key to set a property, and assign a value to a number key to set a child. Events can be connected to by assigning a function to a string key. + +::: warning +When creating an instance with no properties, it is important to not forget to +actually call the constructor: `create "Frame" {}` and not `create "Frame"`. +To be clear, `create "Frame"` returns a *function* which is a constructor for +that class, not an instance of that class. This would result in you attempting +to parent a function instead of an instance which is not correct. +::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 0b803ca..f6b3ad4 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -10,7 +10,7 @@ together. ::: code-group -```luau [Button.luau] +```lua [Button.luau] local create = vide.create local function Button(props: { @@ -34,12 +34,13 @@ 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), @@ -58,8 +59,25 @@ local function Menu() } } end + +mount(App, game.StarterGui) ``` ::: +Above is a simple example of a button component being used across files. + A single parameter `props` is used to pass properties to the component. + +You can only modify the component in ways that you allow in the component, +through the `props` parameter. + +To create a new button all you must do is call the `Button` function, passing in +values. This saves having to create and set every property each time. Also, when +updating the button component in future, any changes to the button file will be +seen anywhere the button is used in your app. + +The `mount()` function is used to set up Vide's reactivity system when creating +your UI. It only needs to be called once at the top-level with the function that +puts together your entire app. It also parents the returned instance to another +a target instance for you. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index d862156..c356b6b 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,11 +1,11 @@ # Sources -Sources are special objects that store a single value and are the core of -Vide's reactivity. +Sources are special objects that store a single value. They are the core of +Vide's reactivity. They are called sources because they act as sources of data. A source can be created using `source()`. -```luau +```lua local source = vide.source local count = source(0) @@ -16,13 +16,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 +35,7 @@ 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. +Sources on their own aren't very special, the above can be achieved with plain +variables. The real use for sources become apparent when used in combination +with *effects*. Similar to a signal and connection, a source and effect allows +you to do things like automatically updating UI when a source is updated. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 82768dc..9f0dd6b 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,11 +1,12 @@ # Effects -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 +called 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 +21,14 @@ 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 initially ran immediately in a +*reactive scope*. Any source read from inside a reactive scope will be tracked, +so that if any of those sources update, the effect will be reran too. -The effect runs its callback once immediately to initially figure out what -sources are being read. - -Derived sources are also tracked, it does not matter how deeply nested +Effects also work with derived sources, it doesn't matter how deeply nested inside a function a source is. -```luau +```lua local source = vide.source local effect = vide.effect @@ -50,22 +49,3 @@ count(2) If a source is updated with the same value it already had, it will not rerun effects depending on it. - -You can also read from a source within an effect without the effect tracking it. - -```luau -local source = vide.source -local effect = vide.effect -local untrack = vide.untrack - -local a = source(0) -local b = source(0) - -effect(function() - print(`a: {a()} b: {untrack(b)}`) -end) - -a(1) -- prints "a: 1 b: 0" -b(1) -- prints nothing -a(2) -- prints "a: 2 b: 1" -``` diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md new file mode 100644 index 0000000..abeec2f --- /dev/null +++ b/docs/tut/crash-course/6-root.md @@ -0,0 +1,74 @@ +# Root Reactive Scopes + +Any reactive scopes created, such as by `effect()`, must be done so within a +"root" reactive scope. This is the main purpose of `mount()`, which you use +once at the top level to create your UI. + +This is so that if you want to destroy your UI, it can stop any reactive scopes +created within it, since reactive scopes track any reactive scopes created +within them. + +```lua +local source = vide.source +local effect = vide.effect + +local function App() + local count = source(0) + + effect(function() + print(count()) + end) +end + +vide.mount(App) -- works! + +App() -- will error since effect() was not called within a reactive scope +``` + +Mounting returns a function that when called will destroy any reactive scopes +created during the `mount()` call. + +```lua +local unmount = mount(App) + +unmount() +``` + +Vide's reactivity can be represented graphically, as a *reactive graph*. + +The reactive graph for the above example looks like so: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" + } +}}%% + +graph + +subgraph root + direction LR + count --> effect +end +``` + +When the root reactive scope created by `mount()` is destroyed, the `effect` +scope will also be destroyed since it was created within it. + +This is important because you may have an effect that updates the property of a +UI instance, meaning the effect is referencing and holding that instance in +memory. The effect being destroyed will remove this reference, allowing the +instance to be garbage collected. + +You don't need to worry about ensuring all your effects are created within a +root scope, since you should be creating all your UI and corresponding effects +within a top-level `mount()` call that puts all your UI together. So it is safe +to assume that any effect you create will be created under this top level scope. +Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md 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/7-stateful-component.md similarity index 66% rename from docs/tut/crash-course/7-reactive-component.md rename to docs/tut/crash-course/7-stateful-component.md index 339ee4f..12f16a2 100644 --- a/docs/tut/crash-course/7-reactive-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,9 +1,13 @@ -# Reactive Components +# Stateful Components -Reactive components in Vide are created using sources and effects - sources to +A stateful component is a component that can update in reponse to data. + +Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. -```luau +## Internal State + +```lua local create = vide.create local source = vide.source local effect = vide.effect @@ -29,15 +33,21 @@ 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 State 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() @@ -48,13 +58,13 @@ 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. +within a reactive scope. diff --git a/docs/tut/crash-course/8-implicit-effect.md b/docs/tut/crash-course/8-property-binding.md similarity index 50% rename from docs/tut/crash-course/8-implicit-effect.md rename to docs/tut/crash-course/8-property-binding.md index 3363bf6..a981b8a 100644 --- a/docs/tut/crash-course/8-implicit-effect.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -1,11 +1,10 @@ -# Implicit Effects +# Property Binding -Explicitly creating effects to update properties is tedious. You can -*implicitly* create an effect to update properties instead. +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. -::: code-group - -```luau [Implicit Effect] +```lua local create = vide.create local source = vide.source @@ -13,54 +12,39 @@ local function Counter() local count = source(0) return create "TextButton" { - Activated = function() - count(count() + 1) - end, - Text = function() return "count: " .. count() - end - } -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. +function will implicitly create an effect to update that property anytime a +source used within is updated. -## Children +Just like effects, the function is ran immediately in a reactive scope to set +the property initially and determine what sources are being used. + +This allows you as the programmer to not need to manually update UI as the state +of your program changes. You just define how data sources map to UI, and Vide's +reactive system will automatically update any properties depending on those +sources. + +## Children Binding Children 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. +instances. Vide will automatically unparent removed instances and parent new +instances when that source's stored instances change. -```luau +```lua local items = source { create "TextLabel" { Text = "A" } } @@ -72,12 +56,12 @@ local function List(props: { children: () -> { Instance } }) } end -local list = List { children = items } -- creates a list with text label "A" +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 text label "A", and parent labels "B" and "C" +-- this will automatically unparent the text label "A", and parent the labels "B" and "C". ``` diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 694d90b..d3b7156 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -2,7 +2,7 @@ We have seen the basic way to derive a source: -```luau +```lua local count = source(0) local text = function() @@ -18,7 +18,7 @@ However, in some cases where this source could be used by multiple effects at the same time, the function wrapping the source will needlessly rerun to convert the count into a string for each effect using it. -```luau +```lua local source = vide.source local effect = vide.effect @@ -32,17 +32,18 @@ end effect(function() text() end) effect(function() text() end) -count(1) -- prints "ran" x2 +source(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. +run a callback in a new reactive scope only when a dependent source has updated. +Reading this derived source multiple times will just return a cached result from +when it last updated. -```luau +```lua local source = vide.source -local effect = vide.effect local derive = vide.derive +local effect = vide.effect local count = source(0) @@ -54,11 +55,10 @@ end) effect(function() text() end) effect(function() text() end) -count(1) -- prints "ran" x1 +source(1) -- prints "ran" x1 ``` -Because `derive()` creates a reactive scope, it must be called within a stable -scope, just like `effect()`. +`derive()` must also be called within a reactive 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. @@ -69,12 +69,12 @@ The reactive graph for the above example: %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#111720", + "primaryColor": "#1B1B1F", "primaryTextColor": "#fff", - "primaryBorderColor": "#111720", + "primaryBorderColor": "#1B1B1F", "lineColor": "#79B8FF", - "tertiaryColor": "#0d131b", - "tertiaryBorderColor": "#0d131b" + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#161618" } }}%% @@ -87,7 +87,6 @@ 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. +another source. You should never manually do this using an effect however, +improper usage could accidently create infinite loops in the reactive graph. +Always favour deriving when you need one source to update based on another. 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/apply.luau b/src/apply.luau index c05fedf..13e8cab 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,20 +1,23 @@ -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 = { +local free_caches: { -- event listeners to connect after properties are set - events: Array< - | string -- 1. event name - | () -> () -- 2. listener + events: Map< + string, -- event name + () -> () -- listener >, -- actions to run after events are connected @@ -23,34 +26,30 @@ type Cache = { Array<(Instance) -> ()> -- action callbacks >, - -- 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 >, + -- use stack instead of recursive function to process nesting 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 } 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 +local function borrow_caches(): typeof(assert(free_caches)) + if free_caches then + local caches = free_caches :: typeof(assert(free_caches)) + free_caches = nil + return caches 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 }), @@ -59,61 +58,36 @@ local function borrow_cache(): Cache end end -local function return_cache(cache: Cache ) - free_cache = cache +local function return_caches(caches: typeof(free_caches) ) + free_caches = caches end -local function process_properties(properties: Map, instance: Instance, cache: Cache, depth: number) - for property, value in properties do - if type(property) == "string" then - if flags.strict then -- check for duplicate property assignment at nesting depth - if cache.nested_debug[depth][property] then - error(`duplicate property {property} at depth {depth}`, 0) - end - cache.nested_debug[depth][property] = true - end - - if property == "Parent" then - cache.parent = value - continue - end - - if type(value) == "function" then - if typeof((instance :: any)[property]) == "RBXScriptSignal" then - table.insert(cache.events, property) -- add event name to buffer - table.insert(cache.events, value :: () -> ()) -- add event listener to buffer - else - implicit_effect.property(instance, property, value :: () -> ()) -- create implicit effect for property - end - else - (instance :: any)[property] = value -- set property - end - elseif type(property) == "number" then - if type(value) == "function" then - implicit_effect.children(instance, value :: () -> ArrayOrV) -- bind children - elseif type(value) == "table" then - if is_action(value) then - table.insert(cache.actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer - elseif flags.defer_nested_properties then - table.insert(cache.nested_stack, value :: {}) - table.insert(cache.nested_stack, depth + 1) -- push table to stack for later processing - else - process_properties(value :: Map, instance, cache, depth + 1) - end - elseif type(value) == "userdata" then - (value :: Instance).Parent = instance -- parent child - end - end - end +-- map of datatype names to class default constructor for aggregate init +local aggregates = {} +for name, class in { + CFrame = CFrame, + Color3 = Color3, + UDim = UDim, + UDim2 = UDim2, + Vector2 = Vector2, + Vector3 = Vector3, + Rect = Rect +} :: Map do + aggregates[name] = class.new 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("attempt to call a constructor returned by create() with no properties") end - local caches = borrow_cache() + local strict = flags.strict + + -- queue parent assignment if any for last + local parent: unknown = properties.Parent + + local caches = borrow_caches() local events = caches.events local actions = caches.actions local nested_debug = caches.nested_debug @@ -122,39 +96,79 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown -- process all properties local depth = 1 repeat - process_properties(properties, instance, caches, depth) + for property, value in properties do + if property == "Parent" then continue end + + if type(property) == "string" then + if strict then -- check for duplicate prop assignment at nesting depth + if nested_debug[depth][property] then + throw(`duplicate property {property} at depth {depth}`) + end + nested_debug[depth][property] = true + end + + if type(value) == "table" then -- attempt aggregate init + local ctor = aggregates[typeof((instance :: any)[property])] + if ctor == nil then + throw(`cannot aggregate type {typeof(value)} for property {property}`) + end + (instance :: any)[property] = ctor(unpack(value :: {})) + elseif type(value) == "function" then + if typeof((instance :: any)[property]) == "RBXScriptSignal" then + events[property] = value :: () -> () -- add event to buffer + else + bind.property(instance, property, value :: () -> ()) -- bind property + end + else + (instance :: any)[property] = value -- set property + end + elseif type(property) == "number" then + if type(value) == "function" then + bind.children(instance, value :: () -> Instance | Array) -- bind children + elseif type(value) == "table" then + if is_action(value) then + table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + else + table.insert(nested_stack, value :: {}) + table.insert(nested_stack, depth + 1) -- push table to stack for later processing + end + else + (value :: Instance).Parent = instance -- parent child + end + end + end + 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) + for event, listener in next, events do + (instance :: any)[event]:Connect(listener) end - for _, queued in actions do - for _, callback in queued do + for _, queued in next, actions do + for _, callback in next, queued 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 + -- clear caches table.clear(events) - for _, queued in actions do table.clear(queued) end - caches.parent = nil - if flags.strict then table.clear(nested_debug) end + for _, queued in next, actions do table.clear(queued) end + if strict then table.clear(nested_debug) end table.clear(nested_stack) - return_cache(caches) + return_caches(caches) return instance end diff --git a/src/batch.luau b/src/batch.luau index e3e6d40..1951789 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -1,23 +1,25 @@ -local flags = require "./flags" -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local flags = require(script.Parent.flags) +local throw = require(script.Parent.throw) +local graph = require(script.Parent.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 + flags.batch = true - local ok, err: string? = xpcall(setter, debug.traceback) + local ok, err: string? = pcall(setter) if not already_batching then flags.batch = false - graph.flush_update_queue(from) + + if not already_batching then + graph.flush_update_queue() + end end - if not ok then error(`error occured while batching updates: {err}`, 0) end + if not ok then throw(`error occured while batching updates: {err}`) end end return batch diff --git a/src/bind.luau b/src/bind.luau new file mode 100644 index 0000000..4a204f1 --- /dev/null +++ b/src/bind.luau @@ -0,0 +1,125 @@ +if not game then script = require "test/relative-string" end + +local trace = require(script.Parent.trace) +local flags = require(script.Parent.flags) +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_node = graph.create_node +local assert_owning_scope = graph.assert_owning_scope +local evaluate_node = graph.evaluate_node +local set_owner = graph.set_owner + +function create_binding(updater: (T) -> T, binding: T) + if flags.strict then + -- track bind creation trace + local fn = updater + local bind_trace = debug.traceback(nil, trace()-1) + updater = function(...) + local ok, result = xpcall(fn, function(err: string) + return err + end, ...) + + if not ok then + local btype = + if (binding :: any).property then (binding :: any).property + elseif (binding :: any).parent then "Parent" + else "children" + error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0) + end + + return result + end + end + + local owner = assert_owning_scope() + + local node = create_node(binding, updater) + + set_owner(node, owner) + evaluate_node(node) +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..519a554 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) diff --git a/src/cleanup.luau b/src/cleanup.luau index 6c46bbf..803be03 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,32 +1,33 @@ -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 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" + else throw("cannot cleanup given object") end local function cleanup(value: unknown) 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 :: () -> ()) + add_cleanup(scope, value :: () -> ()) else - push_cleanup(scope, helper(value)) + add_cleanup(scope, helper(value)) end end @@ -35,7 +36,6 @@ type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } return cleanup :: ( (callback: () -> ()) -> () ) & - ( (thread: thread) -> () ) & ( (instance: Destroyable) -> () ) & ( (connection: Disconnectable) -> () ) & ( (instance: Instance) -> () ) & diff --git a/src/context.luau b/src/context.luau 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..7adfc68 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,119 +1,84 @@ -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 function create_constructor_for_class(class: string): ({ [unknown]: unknown }) -> Instance - local function constructor(properties: { [unknown]: unknown }): Instance +local ctor_cache = {} :: { [string]: () -> Instance } + +setmetatable(ctor_cache :: any, { + __index = function(self, class) local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then error(`invalid class name {class}`, 0) end + 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 + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in next, default do + (instance :: any)[i] = v end end - return apply(instance, properties) - end + local function ctor(properties: Props): Instance + return apply(instance:Clone(), properties) + end - return constructor + self[class] = ctor + return ctor + end +}) + +local function create_instance(class: string) + return ctor_cache[class] 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) +local function clone_instance(instance: Instance) + return function(properties: Props): Instance + local clone = instance:Clone() + if not clone then throw "attempt to clone a non-archivable instance" end + return apply(clone, properties) end +end - local constructor: ({ [unknown]: unknown }) -> Instance +local function create(class_or_instance: string|Instance): (Props) -> 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)) + return nil :: never end - - return if properties - then constructor(properties) - else constructor 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..863fd98 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 assert_owning_scope = graph.assert_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 = assert_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..bbe1669 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 assert_owning_scope = graph.assert_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 = assert_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..cc2d2f8 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, batch = false } diff --git a/src/graph.luau b/src/graph.luau index fd22489..ecda1ab 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 } @@ -10,40 +13,24 @@ export type Node = { effect: ((T) -> T) | false, cleanups: { () -> () } | false, - context: { [number]: unknown } | false, - owned: { Node } | false, owner: Node | false, - parents: { SourceNode }, + parents: { StartNode }, [number]: Node -- children } -local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack - -local function efn(err: string) - local trace = debug.traceback(err, 2) - - if string.find(err, "^effect error stacktrace") then -- if effect error is nested - trace = string.gsub(" " .. trace, "\n", function() -- indent entire error - return "\n " - end) - end - - trace ..= "\nsource update stacktrace:" -return trace -end +-- reactive scope stack +local scopes = { n = 0 } :: { [number]: Node, n: number } 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) + local thread = coroutine.create(pcall) + local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) assert(resume_ok) if coroutine.status(thread) ~= "dead" then - return false, debug.traceback(thread, "attempt to yield in reactive scope") + return false, "attempt to yield in reactive scope" end return run_ok, result @@ -53,37 +40,46 @@ local function get_scope(): Node? return scopes[scopes.n] end -local function assert_stable_scope(): Node +local function assert_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("cannot create new reactive scope inside of a tracking scope") -- todo: allow this? 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.owner = owner + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end +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,39 +87,34 @@ 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 idx = table.find(t, v) :: number local n = #t - t[i] = t[n] + t[idx] = t[n] t[n] = nil end local function unparent(node: Node) local parents = node.parents - for i, parent in parents do + for i, parent in next, parents do find_and_swap_pop(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 @@ -147,55 +138,44 @@ 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 - - local initial_value = node.cache - - for i = 1, 2 do - local cur_value = node.cache - - flush_cleanups(node) - destroy_owned(node) - - push_scope(node) - local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) - pop_scope() - - if not ok then - table.clear(update_queue) - update_queue.n = 0 - error(`effect error stacktrace\n{new_value :: string}`, 0) - end - - node.cache = new_value :: T - end - - return initial_value ~= node.cache - else - local cur_value = node.cache - - flush_cleanups(node) + run_cleanups(node) destroy_owned(node) - push_scope(node) - local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) - pop_scope() + open_scope(node) - 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 + local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) + + close_scope() + + if not ok then throw(new_value :: string) end + + node.cache = new_value :: T end + + run_cleanups(node) + destroy_owned(node) + + open_scope(node) + + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) + + close_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`side-effect error from source update\n{new_value}`) + end + + node.cache = new_value + + return cur_value ~= new_value end -local function queue_children_for_update(node: SourceNode) +local function queue_children(node: StartNode) local i = update_queue.n while node[1] do i += 1 @@ -205,30 +185,34 @@ local function queue_children_for_update(node: SourceNode) update_queue.n = i end -local function get_update_queue_length() - return update_queue.n -end +local _flushing = false +local function flush_update_queue() + assert(not _flushing, "recursive queue flush occured") -- todo + _flushing = true -local function flush_update_queue(from: number) - local i = from + 1 + local n0 = 0 + + local i = n0 + 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) + queue_children(node) end update_queue[i] = false :: any i += 1 end - - update_queue.n = from + + update_queue.n = n0 + + _flushing = false end -local function update_descendants(root: SourceNode) +local function update(root: StartNode) local n0 = update_queue.n - queue_children_for_update(root) + queue_children(root) if flags.batch then return end @@ -239,7 +223,7 @@ local function update_descendants(root: SourceNode) -- check if node is still owned in case destroyed after queued if node.owner and evaluate_node(node) then - queue_children_for_update(node) + queue_children(node) end update_queue[i] = false :: any -- false instead of nil to avoid sparse @@ -249,39 +233,27 @@ local function update_descendants(root: SourceNode) update_queue.n = n0 end -local function push_scope_as_child_of(node: SourceNode) +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, + owner = false, 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 +261,22 @@ 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, + assert_owning_scope = assert_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..840f7f9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,12 +1,113 @@ -assert(game, "when using vide outside of Roblox, require lib.luau instead") +-------------------------------------------------------------------------------- +-- vide.luau +-- v0.2.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 derive = require(script.derive) +local cleanup = require(script.cleanup) +local untrack = require(script.untrack) +local read = require(script.read) +local batch = require(script.batch) +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 = read, + batch = batch, + + -- 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..ef36209 --- /dev/null +++ b/src/maps.luau @@ -0,0 +1,228 @@ +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 assert_owning_scope = graph.assert_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 = assert_owning_scope() + + local subowner = create_node(false, false) + set_owner(subowner, owner) + + local input_cache = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local remove_queue = {} :: { K } + local scopes = {} :: Map> + + local function update_children(data) + -- queue removed values + for i in next, input_cache do + if data[i] == nil then + table.insert(remove_queue, i) + end + end + + -- remove queued values + for _, i in next, remove_queue do + destroy(scopes[i]) + + input_cache[i] = nil + output_cache[i] = nil + input_nodes[i] = nil + scopes[i] = nil + end + + table.clear(remove_queue) + + open_scope(subowner) + + -- process new or changed values + for i, v in next, data do + local cv = input_cache[i] + + if cv ~= v then + if cv == nil then -- create new scope and run transform + local scope = create_node(false, false) + scopes[i] = scope :: Node + + local node = create_start_node(v) + + set_owner(scope, subowner) + open_scope(scope) + + local ok, result = pcall(transform, function() + track(node) + return node.cache + end, i) + + close_scope() + + if not ok then + close_scope() -- subowner scope + error(result, 0) + end + + input_nodes[i] = node + output_cache[i] = result + else -- update source + input_nodes[i].cache = v + update(input_nodes[i]) + end + + input_cache[i] = v + end + end + + close_scope() + + local output_array = table.create(#scopes) + for _, v in next, output_cache do + table.insert(output_array, v) + end + check_primitives(output_array) + + return output_array + end + + local node = create_node(false :: any, function() + return update_children(input()) + end) + set_owner(node, owner) + + evaluate_node(node) + + return function() + track(node) + return node.cache + end +end + +local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } + local owner = assert_owning_scope() + + local subowner = create_node(false, false) + set_owner(subowner, owner) + + local cur_input_cache_up = {} :: Map + local new_input_cache_up = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local scopes = {} :: Map> + + local function update_children(data: Map) + local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up + + if flags.strict then + local cache = {} + for _, v in next, data do + if cache[v] ~= nil then + throw "duplicate table value detected" + end + cache[v] = true + end + end + + open_scope(subowner) + + -- process data + for i, v in next, data do + new_input_cache[v] = i + + local cv = cur_input_cache[v] + + if cv == nil then -- create new scope and run transform + local scope = create_node(false, false) + scopes[v] = scope :: Node + + local node = create_start_node(i) + + set_owner(scope, subowner) + open_scope(scope) + + local ok, result = pcall(transform, v, function() + track(node) + return node.cache + end) + + close_scope() + + if not ok then + close_scope() -- subowner scope + error(result, 0) + end + + input_nodes[v] = node + output_cache[v] = result + else -- update source + if cv ~= i then + input_nodes[v].cache = i + update(input_nodes[v]) + end + + cur_input_cache[v] = nil + end + end + + close_scope() + + -- remove old values + for v in next, cur_input_cache do + destroy(scopes[v]) + + output_cache[v] = nil + input_nodes[v] = nil + scopes[v] = nil + end + + -- update buffer cache + table.clear(cur_input_cache) + cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache + + local output_array = table.create(#scopes) + for _, v in next, output_cache do + table.insert(output_array, v) + end + check_primitives(output_array) + + return output_array + end + + local node = create_node(false :: any, function() + return update_children(input()) + end) + set_owner(node, owner) + + evaluate_node(node) + + return function() + track(node) + return node.cache + end +end + +return function() return indexes, values end diff --git a/src/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 index 3764315..d3a2fb7 100644 --- a/src/read.luau +++ b/src/read.luau @@ -1,3 +1,5 @@ +if not game then script = require "test/relative-string" end + local function read(value: T | () -> T): T return if type(value) == "function" then value() else value end 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..dd633bb 100644 --- a/src/source.luau +++ b/src/source.luau @@ -1,17 +1,19 @@ -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.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 +local create_start_node = graph.create_start_node +local track = graph.track +local update = graph.update export type Source = (() -> T) & ((value: 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..91f17b1 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 assert_owning_scope = graph.assert_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,15 +146,17 @@ 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 = assert_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 @@ -146,117 +164,63 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- todo: is there a solution other than reducing step size? -- todo: this does not catch all solver exploding cases if c > UPDATE_RATE*2 then -- solver will explode if this is true - 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 +230,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..12fd376 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 assert_owning_scope = graph.assert_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 = assert_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 :: U?, 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..70b7973 --- /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..86cdb7b 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,4 +1,6 @@ -local graph = require "./graph" +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) type Node = graph.Node local get_scope = graph.get_scope @@ -10,16 +12,16 @@ local function untrack(source: () -> T): T local effect = scope.effect scope.effect = false - local ok, result = xpcall(source, debug.traceback) + local ok, result = pcall(source) scope.effect = effect :: () -> () if not ok then error(result, 0) end - return result :: T + return result else return source() end 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 77% rename from test/benchmarks.luau rename to test/benchmark.luau index cbb55f9..bee8c68 100644 --- a/test/benchmarks.luau +++ b/test/benchmark.luau @@ -1,17 +1,14 @@ -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) @@ -27,7 +24,8 @@ local function ROOT_BENCH(name: string, fn: () -> ()) end)() end -local N = 2^20 +local N = 2^18 -- 262144 + TITLE "sources" @@ -200,7 +198,7 @@ end) TITLE "property apply" ROOT_BENCH("apply 0 properties", function() - local apply = require "../src/apply" + local apply = require "src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -209,7 +207,7 @@ ROOT_BENCH("apply 0 properties", function() end) ROOT_BENCH("apply 8 properties", function() - local apply = require "../src/apply" + local apply = require "src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -227,7 +225,7 @@ ROOT_BENCH("apply 8 properties", function() end) ROOT_BENCH("bind property", function() - local apply = require "../src/apply" + local apply = require "src/apply" local instance = create("Frame") {} local src = source(1) @@ -242,7 +240,7 @@ ROOT_BENCH("bind property", function() end) ROOT_BENCH("update binding", function() - local apply = require "../src/apply" + local apply = require "src/apply" local instance = create("Frame") {} local src = source(1) @@ -449,99 +447,106 @@ ROOT_BENCH("values() all remove", function() src(data) end) -TITLE "context()" +N *= 1024 -ROOT_BENCH("set context", function() - local ctx = context() +TITLE "cleanup" + +ROOT_BENCH("register new cleanup", function() + local cleanup = cleanup + + local cleaner = function() end + + local callers = {} + + for i = 1, N do + callers[i] = function(fn, v) + fn(v) + return i -- return unique upvalue to ensure unique closure + end + end 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 + ROOT_BENCH("set explicit mock vector2", function() + local apply = require "src/apply" + local Vector2 = require "test/mock".Vector2 + + local label = create "TextLabel" { + AnchorPoint = Vector2.new(1, 1) + } - 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() + ROOT_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 +-- ROOT_BENCH("spring update", function() +-- local root, source, spring = vide.root, vide.source, vide.spring - local src = source(0) +-- local src = source(0) - root(function() - for i = 1, N do - spring(src) - end +-- root(function() +-- for i = 1, N do +-- spring(src) +-- end - START(N) +-- START(N) - src(1) +-- src(1) - return nil - end) -end) +-- return nil +-- end) +-- end) -ROOT_BENCH("spring step", function() - local root, source, spring = vide.root, vide.source, vide.spring +-- N /= 1024 - local src = source(0) +-- ROOT_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/tests.luau b/test/tests.luau index af6a7f4..c38c6a7 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,28 @@ 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 set_owner = graph.set_owner 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,12 +64,12 @@ 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) @@ -97,30 +78,33 @@ TEST("graph", function() 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() + + set_owner(b, root) + set_owner(c, root) 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 @@ -128,18 +112,22 @@ TEST("graph", function() -- 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() + + set_owner(b, root) + set_owner(c, root) + set_owner(d, root) local b_cnt, c_cnt, d_cnt = 0, 0, 0 function b.effect(x) b_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end function d.effect(x) d_cnt += 1; return not x end - 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) @@ -148,17 +136,21 @@ TEST("graph", function() 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() + + set_owner(a, root) + set_owner(b, root) + set_owner(c, root) 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(type(c.effect) == "function" and c.effect)(NIL); close_scope() - update_descendants(a) + update(a) CHECK(#get_children(a) == 1) CHECK(#get_children(b) == 1) @@ -167,13 +159,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 +180,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) + set_owner(items_updated, root) + do open_scope(items_updated) + track(items) - do push_scope(root) - do push_scope(scope1) + do open_scope(root) + set_owner(scope1, root) + do open_scope(scope1) clean "scope1" - bind1 = node(scope1) + bind1 = node() - do push_scope(bind1) + set_owner(bind1, scope1) + 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) + set_owner(scope2, root) + do open_scope(scope2) clean "scope2" - bind2 = node(scope2) - do push_scope(bind2) + bind2 = node() + set_owner(bind2, scope2) + 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 @@ -238,8 +235,8 @@ TEST("graph", function() 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 @@ -270,7 +267,7 @@ TEST("graph", function() 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 +294,41 @@ TEST("graph", function() ^ depth=1 - _, _ <- attempt to update_descendants nothing + _, _ <- attempt to update nothing ^ ]] + local a, b, c, d, e, f = node(), node(), node(), node(), node(), node() + local root = node() - local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(root) + set_owner(a, root) + set_owner(b, root) + set_owner(c, root) + set_owner(d, root) + set_owner(e, root) + set_owner(f, root) 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 +358,9 @@ TEST("mount()", function() end) TEST("root()", function() + local root = vide.root + local cleanup = vide.cleanup + local count = 0 root(function(destroy) @@ -360,6 +372,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 +439,11 @@ TEST("source()", wrap_root(function() end)) TEST("derive()", wrap_root(function() + local source = vide.source + local derive = vide.derive + local effect = vide.effect + local cleanup = vide.cleanup + do CASE "derive new value on source change" local a = source(1) local b = source(2) @@ -511,7 +531,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 @@ -584,6 +604,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 +660,15 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() + local root = vide.root + local source = vide.source + local effect = vide.effect + local cleanup = vide.cleanup + do CASE "root cleanup" local count = 0 - local destroy = mount(function() + local destroy = vide.mount(function() cleanup(function() count += 1 end) @@ -712,21 +741,12 @@ TEST("cleanup()", wrap_root(function() 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 +770,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 +828,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 +850,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 +916,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?) @@ -946,7 +943,7 @@ TEST("create()", wrap_root(function() end do CASE "recursive create" - local set_test_to_true = action(function(self) (self :: any).test = true end) + local set_test_to_true = vide.action(function(self) (self :: any).test = true end) local f2 @@ -972,84 +969,6 @@ TEST("create()", wrap_root(function() 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,148 +997,29 @@ 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 + local untrack = vide.untrack + local cleanup = vide.cleanup + local source = vide.source + local effect = vide.effect + local show = vide.show + local root = vide.root - local output = show(input, one) - - CHECK(output() == 1) - input(false) - CHECK(output() == nil) - end - - do CASE "fallback component" - local input = source(true) + do CASE "main" + -- uses switch() internally, more extensive testing of scoping not needed + local value = source("truey" :: unknown) local function one() return 1 end local function two() return 2 end - local output = show(input, one, two) + local output = show(value, one, two) CHECK(output() == 1) - input(false) + value(nil) 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) + do CASE "alt" + local visible = vide.source(true) + local count = vide.source(0) local outer = 0 local inner = 0 @@ -1240,6 +1040,7 @@ TEST("show()", wrap_root(function() destroyed += 1 end) end) + return nil end) end) end) @@ -1263,161 +1064,14 @@ TEST("show()", wrap_root(function() CHECK(inner == 4) CHECK(destroyed == 3) end - - do CASE "delay (destruction)" - local input = source(false) - - local obj = {} - local value_upval - local present_upval - local cleaned = false - - local output = show(input, function(value, present) - value_upval = value - present_upval = present - cleanup(function() cleaned = true end) - CHECK(present() == false) - return obj, 1 - end) - - CHECK(output() == nil) - - input(true) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - - input(false) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - step(0.5) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - step(0.5 + 0.01) - - CHECK(output() == nil) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(cleaned) - end - - do CASE "delay (reactivate before destruction)" - local input = source(false) - - local obj = {} - local value_upval - local present_upval - local cleaned = false - - local output = show(input, function(value, present) - value_upval = value - present_upval = present - cleanup(function() cleaned = true end) - CHECK(present() == false) - return obj, 1 - end) - - CHECK(output() == nil) - - input(true) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - - input(false) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - step(0.5) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == false) - CHECK(not cleaned) - - input(true) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - - step(0.5 + 0.01) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - end - - do CASE "delay (with fallback)" - local input = source(false) - - local obj = {} - local value_upval - local present_upval - local cleaned = false - - local obj_fallback = {} - local present_fallback_upval - local cleaned_fallback = false - - local output = show(input, function(value, present) - value_upval = value - present_upval = present - cleanup(function() cleaned = true end) - CHECK(present() == false) - return obj, 1 - end, function(present) - present_fallback_upval = present - cleanup(function() cleaned_fallback = true end) - CHECK(present() == false) - return obj_fallback, 1 - end) - - CHECK(output() == obj_fallback) - CHECK(value_upval == nil) - CHECK(present_upval == nil) - CHECK(not cleaned) - CHECK(present_fallback_upval() == true) - CHECK(not cleaned_fallback) - - input(true) - - CHECK(type(output() == "table") and table.find(output(), obj) and table.find(output(), obj_fallback)) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - CHECK(present_fallback_upval() == false) - CHECK(not cleaned_fallback) - - step(1 + 0.01) - - CHECK(output() == obj) - CHECK(value_upval() == true) - CHECK(present_upval() == true) - CHECK(not cleaned) - CHECK(present_fallback_upval() == false) - CHECK(cleaned_fallback) - end end)) TEST("switch()", wrap_root(function() + local source = vide.source + local switch = vide.switch + local effect = vide.effect + local cleanup = vide.cleanup + do CASE "update on source change" local input = source(true) @@ -1445,27 +1099,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 +1157,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 @@ -1535,13 +1190,15 @@ TEST("switch()", wrap_root(function() vide.strict = false end - - do CASE "delay" - -- probably unneeded because show() uses switch() internally - end end)) TEST("indexes()", wrap_root(function() + local create = vide.create + local source = vide.source + local effect = vide.effect + local indexes = vide.indexes + local cleanup = vide.cleanup + do CASE "use source" local input = source { 1, 2, 3 } @@ -1559,7 +1216,7 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local _, output = root(function() + local output = vide.root(function() local output = indexes(input, function(v, i) count[i] += 1 return v @@ -1625,12 +1282,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 +1316,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 } @@ -1734,84 +1388,14 @@ TEST("indexes()", wrap_root(function() vide.strict = false end - - do CASE "delay" - local input = source {} - - local cleaned_counts = {} :: Map - - local output = indexes(input, function(v, i, present) - cleanup(function() - cleaned_counts[i] = (cleaned_counts[i] or 0) + 1 - end) - return { value = v, index = i, present = present }, 1 - end) - - local function mapped() - local map = {} - local objects = output() - if objects then - for _, object in objects do - map[object.index] = { value = object.value, present = object.present } - end - end - return map - end - - ------------------------------------------------------------------------ - - do - CHECK(mapped()[1] == nil) - end - - input { 1, 2 } - - do - CHECK(mapped()[1].value() == 1) - CHECK(mapped()[1].present()) - - CHECK(mapped()[2].value() == 2) - CHECK(mapped()[2].present()) - end - - input { 2 } - step(0.5) - - do - CHECK(mapped()[1].value() == 2) - CHECK(mapped()[1].present()) - - CHECK(mapped()[2].value() == 2) - CHECK(not mapped()[2].present()) - end - - input { 1, 2 } - step(0.5 + 0.01) - - do - CHECK(mapped()[1].value() == 1) - CHECK(mapped()[1].present()) - - CHECK(mapped()[2].value() == 2) - CHECK(mapped()[2].present()) - end - - input { 3 } - step(1 + 0.01) - - do - CHECK(mapped()[1].value() == 3) - CHECK(mapped()[1].present()) - - CHECK(not mapped()[2]) - - CHECK(cleaned_counts[1] == nil) - CHECK(cleaned_counts[2] == 1) - end - end end)) TEST("values()", wrap_root(function() + local create = vide.create + local source = vide.source + local values = vide.values + local cleanup = vide.cleanup + do CASE "use source" local input = source { 1, 2, 3 } @@ -1911,7 +1495,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 +1514,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 +1529,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 +1595,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 +1606,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 source = vide.source + local effect = vide.effect + local derive = vide.derive + local untrack = vide.untrack + local cleanup = vide.cleanup + local root = vide.root + do CASE "does not register dependency" local a = source(0) local b = source(0) @@ -2201,7 +1678,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 +1725,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 +1753,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 +1789,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) @@ -2321,7 +1808,7 @@ TEST("changed()", wrap_root(function() end do CASE "connection disconnected" - local _, text, destroy = root(function(destroy) + local text, destroy = root(function(destroy) local output = source(nil) return create "TextLabel" { @@ -2341,6 +1828,10 @@ TEST("changed()", wrap_root(function() end)) TEST("batch()", wrap_root(function() + local source = vide.source + local derive = vide.derive + local batch = vide.batch + do CASE "evaluation deferred" local a = source(0) @@ -2417,213 +1908,13 @@ TEST("batch()", wrap_root(function() 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 +1924,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,105 +1938,14 @@ 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 vide = require "src/init" + local source = vide.source + local effect = vide.effect + local untrack = vide.untrack + local cleanup = vide.cleanup + local root = vide.root + local ran = 0 local cleaned = 0 @@ -2787,6 +1987,11 @@ TEST("nested effects cases", function() end) TEST("graph edge cases", wrap_root(function() + local source = vide.source + local derive = vide.derive + local effect = vide.effect + local root = vide.root + do CASE "diamond A,B,C,D" --[[ @@ -2873,7 +2078,7 @@ TEST("graph edge cases", wrap_root(function() do CASE "do not destroy children" local parent = source(0) - local _, + local destroy, parent_to_destroy, update_parent_to_destroy @@ -2916,7 +2121,7 @@ TEST("graph edge cases", wrap_root(function() -- child B reevaluates due to already being queued -- parent destroys, destroys child B - uh oh - local _, + local destroy_parent, parent, update_parent @@ -2928,7 +2133,7 @@ TEST("graph edge cases", wrap_root(function() src end) - local _, destroy_child, _child_B = nil, function() end, nil + local destroy_child, _child_B = function() end, nil local count_A = 0 @@ -2957,6 +2162,12 @@ end)) TEST("strict", wrap_root(function() vide.strict = true + local create = vide.create + local source = vide.source + local derive = vide.derive + local effect = vide.effect + local indexes, values = vide.indexes, vide.values + do CASE "error on derived callback yield" local src = source(1) @@ -3011,6 +2222,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 } @@ -3064,99 +2285,6 @@ TEST("strict", wrap_root(function() src(not src()) CHECK(count == 4) end - - do CASE "effect using derived source" - local input = source(true) - - local output = derive(function() - return input() - end) - - local count = 0 - - effect(function() - output() - count += 1 - end) - - CHECK(count == 2) - - input(false) - - CHECK(count == 4) - end - - do CASE "destruction of active scope" - local src = source(false) - local count = 0 - - root(function() - show(src, function() - src(false) - cleanup(function() count += 1 end) - return {} - end) - end) - - local ok = pcall(function() - src(true) - end) - - CHECK(count == 0) - CHECK(not ok) - end - - do CASE "destruction of active scope in indexes" - local src = source {} - - local count_1 = 0 - local count_2 = 0 - - root(function() - effect(function() - untrack(function() - indexes(src, function() - cleanup(function() count_1 += 1 end) - src {} - cleanup(function() count_2 += 1 end) - return {} - end) - end) - end) - end) - - local ok = pcall(function() - src { 1 } - end) - - CHECK(not ok) - end - - do CASE "destruction of active scope in values" - local src = source {} - - local count_1 = 0 - local count_2 = 0 - - root(function() - effect(function() - untrack(function() - values(src, function() - cleanup(function() count_1 += 1 end) - src {} - cleanup(function() count_2 += 1 end) - return {} - end) - end) - end) - end) - - local ok = pcall(function() - src { {} } - end) - - CHECK(not ok) - end end)) local ok = FINISH() diff --git a/wally.toml b/wally.toml index 1584e06..d4d48ed 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.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"]