Compare commits

...

119 commits
0.1 ... main

Author SHA1 Message Date
raine
f3bfc65607
feat: export vide.create types and include more ui element types (#79)
* feat: add more ui instances to Create type

* fix: use string requires in init.luau

@self string requires are now supported by Roblox, so this should be fine. It works in my Pesde patches and fixes issues with missing types.

* feat: export vide.create types

Instances and Properties are both useful types that should be able to be used by consumers (e.g. wrapper components).

* fix: typo

* feat: include exports in repo init.luau

Fixes #84
2026-08-05 17:43:28 +01:00
centauri
452060a533 Bump wally and pesde version 2026-07-11 13:31:48 +01:00
centauri
5ed4c01940 Bump version to 0.4.1 2026-07-11 13:25:38 +01:00
centauri
19eaeb424f Deprecate create() overloads
Closes #70
2026-04-29 21:33:29 +01:00
centauri
7deae9c9f6 Allow vide.branch to be used in a reactive scope 2026-03-10 13:46:13 +00:00
centau
3faa1ed701
Merge pull request #64 from wiam77/patch-1
Simplify connector check in Properties type function
2026-01-26 12:09:00 +00:00
wiam
820bda8eb1
Simplify connector check in Properties type function
Removed redundant check for connector in the Properties type function.
2026-01-25 23:15:48 -03:00
centauri
2184a0fa6a Bump version to 0.4.0 2026-01-17 20:28:45 +00:00
centauri
6da33722e9 Add delayed scope destruction to docs 2026-01-17 20:15:05 +00:00
centau
a0eca04903
Merge pull request #63 from devsarim/main
add Camera and WorldModel to list of instances recognized by `vide.cr…
2026-01-16 21:04:49 +00:00
Sarim Siddiqui
cb6022d934 add Camera and WorldModel to list of instances recognized by vide.create 2026-01-03 23:12:27 +05:00
centauri
cf58410b89 Fix spring impulse control 2025-12-04 14:22:16 +00:00
centauri
f370e3f841 Fix type error when not specifying scope destroy delay 2025-11-26 12:38:39 +00:00
centauri
d7d2f5167e Use type functions for vide.create 2025-11-26 12:35:34 +00:00
centauri
c16022586e Add create type test 2025-11-03 00:51:09 +00:00
centau
0e41514860
Merge pull request #61 from ernisto/fix/spring
fix(spring tolerance): guarantee a minimun movement to goal
2025-10-28 17:02:33 +00:00
ernisto
800396fa0e feat(spring): handle goal reached before update spring sources 2025-10-24 22:58:12 -03:00
ernisto
fb10fc2b0d fix(spring tolerance): guarantee a minimun movement to goal 2025-10-24 20:00:46 -03:00
centauri
9ace23470f Use generalized iteration everywhere
Closes #55
2025-09-02 03:12:19 +01:00
centauri
a469094328 Do not auto deploy site on push to main 2025-09-01 22:22:09 +01:00
centauri
bc2bb25f74 Add vide.default type 2025-09-01 22:19:24 +01:00
centauri
945cdd0e88 Update changelog 2025-09-01 22:09:06 +01:00
centauri
ce24f8ade9 Cleanup scope functions 2025-09-01 21:59:35 +01:00
centauri
4b4db9602e Implement delays for indexes() and values() 2025-08-31 20:37:46 +01:00
centauri
5262ef711c Implement delays for show() and switch() 2025-08-29 17:44:02 +01:00
centauri
2518e292ed Update spring docs 2025-06-23 21:58:54 +01:00
centauri
5f752fe903 Add flag to disable setting of default properties 2025-06-23 21:36:51 +01:00
centauri
3c2fafa1cd Fix require paths for latest luau 2025-06-23 20:44:14 +01:00
centauri
5b078d118e Update actions and tooling 2025-06-23 19:09:21 +01:00
alicesaidhi
86f8574aeb
Add GitHub workflow to build rbxm and publish to pesde and wally (#50)
* setup workflow

* change versions

* fix project json

* fix pesde and wally workflow

* update pesde toml

* wrap tokens ins trings

* update versions

* ignore lock files
2025-06-23 18:31:40 +01:00
aaron
6cf770df4e Add spring support for array of numbers 2025-03-27 23:41:01 +00:00
aaron
452ca383f7 Optimize indexes() and values() 2025-03-27 19:27:54 +00:00
EwDev
37e8e05206
Support nesting Parent (#48)
* allow nesting parent property

* update changelog

* fix mock instances Parent property not being a proxy

* add tests

Closes #47
2025-02-23 21:31:26 +00:00
HarryXChen
7064489a36
Fix typo in scope crash course docs (#46) 2025-01-17 02:06:54 +00:00
aaron
b7878753bd Update untrack() type to allow no return 2025-01-07 00:06:39 +00:00
aaron
a383c4ce69 Fix show() edge case 2025-01-06 23:56:49 +00:00
aaron
46a2043356 Ignore false passed as a child 2025-01-04 00:45:55 +00:00
aaron
c796e48173 Add source to show() callback 2025-01-04 00:40:51 +00:00
aaron
58a31a1b32 Try improve error reporting 2024-12-27 22:43:01 +00:00
aaron
3b22f6ccf9 Add spring setter 2024-12-27 02:34:31 +00:00
aaron
b44b9ef2ba Add implicit effects recursively creating more implicit effects for children 2024-12-27 01:00:45 +00:00
aaron
4c639f8388 Add flag to disable deferral of nested properties 2024-12-26 22:36:58 +00:00
aaron
ccaeb030f3 Remove aggregate construction 2024-12-26 21:17:14 +00:00
richard
3b8d9098c0
Use vector.max to check spring activity (#44)
* Use `vector.max` to check spring activity

* Revert unnecessary changes
2024-12-05 02:00:37 +00:00
aaron
caa9eaf733 Add thread cleanup helper 2024-11-28 01:00:11 +00:00
aaron
e60aa57ca2 Fix edge case with map functions 2024-11-27 19:43:51 +00:00
11poe
44fe65ee5e
Add create(a, { props }) syntax (#42)
* Add new create syntax

* Add new create syntax to changelog

* Fix new create syntax test
2024-11-16 20:20:49 +00:00
aaron
8799988851 Update github workflow Luau version 2024-11-16 18:55:37 +00:00
aaron
fdb4a137a8 Fix test require 2024-11-16 18:41:27 +00:00
aaron
f7e9961911 Fix library require 2024-11-16 18:38:23 +00:00
aaron
49cc551493 Switch from mock Vector3 to native vector lib 2024-11-13 22:35:16 +00:00
aaron
5abd5eee91 Update require paths to relative string 2024-11-13 22:19:20 +00:00
aaron
0897821e1f Add check for destruction of active scope 2024-11-13 21:56:22 +00:00
aaron
a1552402cb Rename tutorial sections 2024-11-12 15:15:16 +00:00
aaron
bacb4fa0f0 Fix mistakes in docs 2024-11-12 15:08:12 +00:00
aaron
baf308ccf3 Fix bug in code example 2024-11-04 23:27:28 +00:00
aaron
a3cc2dfbda Update docs 2024-11-04 23:18:04 +00:00
aaron
94add5d452 Update vitepress version 2024-11-03 17:46:45 +00:00
aaron
1edcd65166 Update docs 2024-11-03 17:37:41 +00:00
alicesaidhi
f8e84f9f8d
Improve Documentation Site (#41)
* update css and snippets

* Add banner

* improve home page

* Update Banner

* cleanup

* Update font to JetBrains Mono

* Add a quick look to Home

* Simplify Home Page

* Removed banner, removed copyright notice, reduced home page
2024-11-03 17:32:19 +00:00
aaron
f7dac3f63a Bump version to 0.3.1 2024-10-09 02:23:06 +01:00
aaron
6ead93088a Make context functions return result 2024-10-09 02:12:25 +01:00
aaron
1d565262e1 Bump version to 0.3.0 2024-10-06 18:16:31 +01:00
aaron
93a2a2c631 Make root() destroy scope if error occurs 2024-10-06 18:14:53 +01:00
aaron
c4e1396684 Fix typos 2024-10-06 18:12:25 +01:00
aaron
82eec61c45 Make root() return destructor automatically 2024-10-06 15:37:05 +01:00
aaron
8142acd1c1 Add context() 2024-10-06 15:20:04 +01:00
EwDev
83db00a073
allow changing the current value of a spring (similar to sources) (#36)
* allow changing the current value of a spring (similar to sources)

* use spaces instead of tabs for identing
2024-09-11 10:11:47 +01:00
alicesaidhi
fbe2f01bb9
fix recursive queue flush (#28)
* fix recursive queue flush

* remove print from test

* swap order of flush

* simplified test case and remove error

* write more in-depth tests

* change approach flush_update_queue

* double diamond test case

* fix indent

* change naming of function

* subsequent updates do not batch test case

* simplify test case
2024-08-20 12:44:56 +01:00
alicesaidhi
b85419088c
allow fragments in implicit effects for children (#29)
* fix fragments in effects

* add test case

* more descriptive test
2024-08-20 12:40:29 +01:00
richard
72e5fbb6fe
Round UDim offset values (#30) 2024-08-19 14:56:00 +01:00
aaron
e1751fb8d0 Fix GitHub action 2024-07-27 02:03:06 +01:00
aaron
31da86c30b Allow indexes() and values() to return functions 2024-07-20 18:08:04 +01:00
aaron
2dc2814d76 Fix regression with derived sources 2024-07-19 11:21:49 +01:00
aaron
4f3fccd3bb Fix root() not showing full error trace 2024-07-17 23:28:43 +01:00
aaron
7bae2517cd Improve error reporting 2024-07-15 17:37:26 +01:00
aaron
14f8d38a35 Update docs 2024-07-03 15:34:16 +01:00
aaron
000def5bc2 Update docs 2024-07-03 15:02:53 +01:00
aaron
62f1c3a20a Update docs 2024-07-03 15:02:53 +01:00
aaron
f2de9b0e63 Refactor 2024-07-03 15:02:52 +01:00
Someon1e
67e87110c7
Delete .gitattributes (#26) 2024-06-23 18:40:24 +01:00
richard
e1d69afa20
Fix springs passing negative Color3 values (#25)
* Fix springs passing negative Color3 values

* Clamp color values between 0 and 1
2024-04-21 13:57:29 +01:00
ReturnedTrue
3d324cc30b
Update strict-mode.md (#24) 2024-02-19 21:06:06 +00:00
Aaron Smith
d682161c06 Update docs 2023-12-07 17:26:01 +00:00
Aaron Smith
3aed45212a Fix site mermaid rendering
Vitepress rc 26 breaks mermaid plugin - use rc 25.
2023-11-24 11:09:32 +00:00
aaron
50244c2bd8 Update wally version 2023-11-22 10:34:02 +00:00
aaron
c527a62ab1 Bump version to 0.2.0 2023-11-22 10:14:00 +00:00
Aaron Smith
338c66ed57 Try improve crash course 2023-11-21 18:48:47 +00:00
Aaron Smith
c288cb92c4 Minor refactors
Also fixed potential bug with `create()` being called recursively if a
property binding passed as a property to `create()` also calls
`create()`
2023-11-20 16:53:30 +00:00
Aaron Smith
8eb5f96c5b Fix recursive batch() use 2023-11-20 12:27:56 +00:00
Aaron Smith
65fe3fcf47 Fix some graph edge cases 2023-11-16 18:01:45 +00:00
Aaron Smith
ec998ccbc8 Implement batched updates
No docs yet, more testing needed.
2023-10-27 16:47:49 +01:00
Aaron Smith
7d82fe353e Fix error in test
Also misc changes
2023-10-27 15:47:43 +01:00
aaron
aca08709b6 Update tests 2023-10-25 20:02:40 +01:00
Aaron Smith
15255855c3 Add repeated read test 2023-10-25 16:58:29 +01:00
Aaron Smith
8218e4702f Fix diamond graphs 2023-10-25 15:49:35 +01:00
Alo
da85cbbac8
Fix docs typo
minor spelling mistake
2023-10-06 23:16:01 +01:00
aaron
7128b9a58b Bump version to v0.1.1 2023-09-30 01:32:14 +01:00
Aaron Smith
c03fb05779 Make changed() run initially 2023-09-28 16:46:32 +01:00
Aaron Smith
46937c6979 Update docs 2023-09-28 16:39:25 +01:00
Aaron Smith
9b6be14441 Update docs 2023-09-27 16:44:39 +01:00
Aaron Smith
3959f5119e Update cleanup() docs 2023-09-27 10:33:38 +01:00
Aaron Smith
bbda0426da Update changelog 2023-09-27 10:31:52 +01:00
Aaron Smith
2ae65a0194 Update tests
Added a case to confirm the behavior of strict mode's double evaluation
and node cached values.
2023-09-27 10:24:14 +01:00
Aaron Smith
d07c705b64 Fix switch() not working in strict mode
Fixes #20
2023-09-27 10:02:09 +01:00
Aaron Smith
fd2888afab Update docs 2023-09-26 18:24:30 +01:00
Aaron Smith
c3bc53c40a Fix typo in docs
Fixes #18
2023-09-26 14:37:48 +01:00
Aaron Smith
32fa44f4c5 Cleanup helper for instances
Closes #19
2023-09-26 14:34:27 +01:00
Aaron Smith
d1f86a3f9e Allow untrack() in non-reactive scopes
Closes #17
2023-09-26 14:11:33 +01:00
Aaron Smith
71bbaa2092 Update reactive scoping docs 2023-09-26 13:27:38 +01:00
aaron
c68d0a6c18 Allow creation of nested tracking scopes
It turns out that handling destruction of a nested tracking scope was
not as difficult as I thought and didn't need much changes
2023-09-25 23:37:51 +01:00
aaron
38342b3805 Disallow creation of nested tracking scopes 2023-09-25 11:44:24 +01:00
aaron
c0c2166edf Update testkit 2023-09-24 01:49:16 +01:00
aaron
15026b5ad3 Update todo 2023-09-22 16:57:14 +01:00
Aaron Smith
e9742e88fd Add mermaid diagrams to docs 2023-09-21 19:15:32 +01:00
Aaron Smith
3c45defeac Fix typos 2023-09-21 19:14:55 +01:00
Aaron Smith
62716a074d Update benchmarks 2023-09-21 14:58:44 +01:00
Aaron Smith
0e8bfef905 Fix typos in creation docs 2023-09-21 10:40:05 +01:00
Aaron Smith
4b877ce8ff Update docs 2023-09-21 10:29:14 +01:00
93 changed files with 5400 additions and 2816 deletions

1
.gitattributes vendored
View file

@ -1 +0,0 @@
*.luau linguist-language=Lua

33
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: release
on:
release:
types: [published]
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: setup rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: build
run: rojo build default.project.json -o build.rbxm
- name: release
run: gh release upload ${{github.event.release.tag_name}} build.rbxm

View file

@ -1,9 +1,6 @@
name: site-deploy name: site-deploy
on: on:
push:
branches: [main] # todo: remove later
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -24,21 +21,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v3 uses: actions/configure-pages@v4
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Build with VitePress - name: Build with VitePress
run: npm run docs:build run: npm run docs:build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v2 uses: actions/upload-pages-artifact@v3
with: with:
path: docs/.vitepress/dist path: docs/.vitepress/dist
@ -52,4 +49,4 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v2 uses: actions/deploy-pages@v4

View file

@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install Luau zip - name: Install Luau zip
uses: robinraju/release-downloader@v1.6 uses: robinraju/release-downloader@v1.9
with: with:
repository: Roblox/luau repository: Roblox/luau
latest: true tag: "0.679"
fileName: luau-ubuntu.zip fileName: luau-ubuntu.zip
out-file-path: bin out-file-path: bin

54
.github/workflows/wallypesde.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: publish to wally and pesde
on:
workflow_dispatch:
release:
types: [published]
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
jobs:
wally:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: setup rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: login
run: wally login --token "${{ secrets.WALLY_TOKEN }}"
- name: publish
run: wally publish
pesde:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: setup rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: setup pesde
run: pesde install
- name: login
run: pesde auth login --token "${{ secrets.PESDE_TOKEN }}"
- name: publish
run: pesde publish --yes

8
.gitignore vendored
View file

@ -1,10 +1,16 @@
.vscode .vscode
_local _local
aftman.toml
sourcemap.json sourcemap.json
docs/.vitepress/dist docs/.vitepress/dist
docs/.vitepress/cache docs/.vitepress/cache
docs/node_modules docs/node_modules
docs/package-lock.json docs/package-lock.json
luau_packages/
lune_packages/
.pesde/
wally.lock
pesde.lock

View file

@ -4,9 +4,122 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased --------------------------------------------------------------------------------
--- ## [0.4.1] - 2026-07-11
### Changed
- `branch()` is now allowed to be used within a reactive scope.
### Deprecated
- `create()` overloads. Supported is now only `create(class)(props)`.
### Fixed
- `create()` types in the new solver should now work without `::`.
--------------------------------------------------------------------------------
## [0.4.0] - 2026-01-17
### Added
- `create("ClassName", { props })` and `create(Instance, { props })` syntax.
- `cleanup()` now accepts `thread` types.
- Implicit effects to set children can now recursively create more implicit
effects to set children.
- `spring()` returns a second value, a setter to set position, velocity and
impulse.
- Improved `spring()` updating and unscheduling.
- `show()` now receives a source to its callback returning the current value
of the condition.
- Ignore `false` passed as a child.
- Flag `vide.defaults` to disable the setting of default properties.
- Delayed scope destruction for control flow functions: `show()` `switch()` `indexes()` `values()`.
- Better `create()` types for the new type solver.
### Changed
- A scope can no longer be destroyed while it is active. Strict mode will check
for this.
- Implicit effects to set children now unparent all children when the effect is
destroyed.
- Error reporting should be improved with better formatting when effects invoke
other effects and no more loss of stack traces.
- Nesting parent properties now work, and they are now also checked for
duplicates like other properties.
### Removed
- Aggregate construction when setting properties with `create()`.
--------------------------------------------------------------------------------
## [0.3.1] - 2024-10-09
### Added
- Context functions now also return results.
- `version` table with current version.
--------------------------------------------------------------------------------
## [0.3.0] - 2024-10-06
### Added
- `context()`.
### Changed
- `root()` now returns its destructor as the first value by default.
### Fixed
- Error stack traces being lost.
- `root()` now destroys the scope automatically if an error occurs during call.
--------------------------------------------------------------------------------
## [0.2.0] - 2023-11-22
### Added
- Batched updates with `batch()`.
### Changed
- Improved graph updating algorithm.
- Graph nodes when destroyed no longer destroy children; only owned.
### Fixed
- Graph edge case where a destroyed node can be readded if it was queued for
rerun before being destroyed.
- Some properties not being applied when `create()` is used recursively.
--------------------------------------------------------------------------------
## [0.1.1] - 2023-09-30
### Added
- `cleanup()` accepts objects with a `Destroy()` or `Disconnect()` interface.
- `read()` as a utility to read sources or passthrough a non-source value.
### Changed
- Reactive scopes created within reactive scopes are now destroyed on rerun.
- `untrack()` can be called outside of reactive scopes.
- `changed()` will also run its callback with the initial property value.
### Fixed
- `show()` and `switch()` not updating when in strict mode.
--------------------------------------------------------------------------------
## [0.1.0] - 2023-09-20 ## [0.1.0] - 2023-09-20

View file

@ -18,7 +18,7 @@ for a quick introduction to the library.
## Code sample ## Code sample
```lua ```luau
local create = vide.create local create = vide.create
local source = vide.source local source = vide.source

View file

@ -1,7 +1,8 @@
import { defineConfig } from "vitepress" //import { defineConfig } from "vitepress"
import { withMermaid } from "vitepress-plugin-mermaid";
// https://vitepress.dev/reference/site-config // https://vitepress.dev/reference/site-config
export default defineConfig({ export default withMermaid({
title: "Vide", title: "Vide",
titleTemplate: ":title - A reactive UI library for Luau", titleTemplate: ":title - A reactive UI library for Luau",
description: "A reactive UI library for Luau.", description: "A reactive UI library for Luau.",
@ -11,6 +12,14 @@ export default defineConfig({
themeConfig: { themeConfig: {
logo: "/logo.svg", logo: "/logo.svg",
search: {
provider: "local"
},
footer: {
message: 'Released under the MIT License.',
},
// https://vitepress.dev/reference/default-theme-config // https://vitepress.dev/reference/default-theme-config
nav: [ nav: [
{ text: "Home", link: "/" }, { text: "Home", link: "/" },
@ -25,7 +34,7 @@ export default defineConfig({
items: [ items: [
{ text: "Reactivity: Core", link: "/api/reactivity-core" }, { text: "Reactivity: Core", link: "/api/reactivity-core" },
{ text: "Reactivity: Utility", link: "/api/reactivity-utility" }, { text: "Reactivity: Utility", link: "/api/reactivity-utility" },
{ text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, { text: "Reactivity: Dynamic Scoping", link: "/api/reactivity-dynamic" },
{ text: "Element Creation", link: "/api/creation" }, { text: "Element Creation", link: "/api/creation" },
{ text: "Animation", link: "/api/animation" }, { text: "Animation", link: "/api/animation" },
{ text: "Strict Mode", link: "/api/strict-mode" }, { text: "Strict Mode", link: "/api/strict-mode" },
@ -42,19 +51,26 @@ export default defineConfig({
{ text: "Components", link: "/tut/crash-course/3-components" }, { text: "Components", link: "/tut/crash-course/3-components" },
{ text: "Sources", link: "/tut/crash-course/4-source" }, { text: "Sources", link: "/tut/crash-course/4-source" },
{ text: "Effects", link: "/tut/crash-course/5-effect" }, { text: "Effects", link: "/tut/crash-course/5-effect" },
{ text: "Stateful Components", link: "/tut/crash-course/6-stateful-component" }, { text: "Scopes", link: "/tut/crash-course/6-scope" },
{ text: "Property Binding", link: "/tut/crash-course/7-property-binding" }, { text: "Reactive Components", link: "/tut/crash-course/7-reactive-component" },
{ text: "Cleanup", link: "/tut/crash-course/8-cleanup" }, { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" },
{ text: "Control Flow", link: "/tut/crash-course/9-control-flow" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" },
{ text: "Property Nesting", link: "/tut/crash-course/10-property-nesting" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" },
{ text: "Actions", link: "/tut/crash-course/11-actions" }, { text: "Dynamic Scopes", link: "/tut/crash-course/11-dynamic-scopes" },
{ text: "Strict Mode", link: "/tut/crash-course/12-strict-mode" }, { 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 Reactivity", text: "Advanced",
items: [
{ text: "Dynamic Scopes", link: "/tut/advanced/dynamic-scopes"}
]
},
{
text: "Design Patterns",
items: [ items: [
{ text: "Reactive Scopes", link: "/tut/advanced/reactive-scoping.md"}
] ]
} }
], ],

View file

@ -0,0 +1,25 @@
.home > * > .VPNavBar:not(.top) {
backdrop-filter: blur(0.5rem);
background-color: transparent !important;
}
.home > * > .VPNavBar > .divider {
opacity: 0;
}
.home > .VPContent {
display: flex;
justify-content: center;
flex-direction: column;
}
.VPHome {
margin-top: auto !important;
margin-bottom: auto !important;
}
.VPBadge a {
text-decoration: none;
color: inherit
}

View file

@ -1,4 +1,8 @@
// .vitepress/theme/index.js // .vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme' import DefaultTheme from 'vitepress/theme'
import './vars.css' import './vars.css'
export default DefaultTheme import './index.css'
export default {
extends: DefaultTheme,
}

View file

@ -1,3 +1,52 @@
/* Colors */
:root { :root {
--vp-c-brand-1: #3086ff; --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;
}

View file

@ -1,25 +1,30 @@
# Animation API # Animation
## spring() ## spring() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Returns a new source with a value always moving torwards the input source value. Returns a new source with a value always moving torwards the input source value.
- **Type** - **Type**
```lua ```luau
function spring<T>( function spring<T>(
source: () -> T & Animatable, source: () -> T & Animatable,
period: number = 1, period: number = 1,
damping_ratio: number = 1 damping_ratio: number = 1
): () -> T ): (() -> T, SpringControl<T>)
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect
type SpringControl<T> = ({
position: T?,
velocity: T?,
impulse: T?
}) -> ()
``` ```
- **Details** - **Details**
The output source value is updated every step based on the input source Creates a reactive scope internally to detect source updates.
value.
The movement is physically simulated according to a The movement is physically simulated according to a
[spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion).
@ -39,3 +44,7 @@ Returns a new source with a value always moving torwards the input source value.
You can change when the solver runs by calling `vide.step(dt)`, which will 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 advance the simulation time by `dt` seconds and automatically stop the
solver running in heartbeat. solver running in heartbeat.
::: warning
Large periods or damping ratios can break the spring.
:::

View file

@ -1,38 +1,4 @@
# Element Creation API # Element Creation
<br/>
## mount()
Runs a function and applies its result to a target instance.
- **Type**
```lua
function mount<T>(component: () -> T, target: Instance?): () -> ()
```
- **Details**
The result of the function is applies to the target in the same way
properties are using `create()`.
The function is ran in a new reactive scope, just like
[root()](reactivity-core.md#root).
Returns a function that when called will destroy the reactive scope.
- **Example**
```lua
local function App()
return create "ScreenGui" {
create "TextLabel" { Text = "Vide" }
}
end
mount(App, game.StarterGui)
```
## create() ## create()
@ -40,11 +6,11 @@ Creates a new UI element, applying any given properties.
- **Type** - **Type**
```lua ```luau
function create(class: string): (Properties) -> Instance function create(class: string): (Properties) -> Instance
function create(instance: Instance): (Properties) -> Instance function create(instance: Instance): (Properties) -> Instance
type Properties = Map<string|number, any> type Properties = Map<string|number, unknown>
``` ```
- **Details** - **Details**
@ -60,86 +26,68 @@ Creates a new UI element, applying any given properties.
- **Property setting rules** - **Property setting rules**
- If a table index is a string: - **index is string:**
- If its value is a function then it will either bind that property to - **value is function:**
the function or connect it if the property type is a `RBXScriptSignal`. - **property is event:** connect function as callback
- If the value is not a function then the property will be set to that - **property is not event:** create effect to update property
value. - **value is not function:** set property to value
- If a table index is a number: - **index is number:**
- If its value is an action then that action will be queued to run after - **value is action:** run action
properties are set. - **value is table:** recurse table
- If its value is a table then that table will be recursively - **value is function:** create effect to update children
processed just like the outer table. - **value is instance:** set instance as child
- If its value is a function then it will bind the instances children to
that function.
- If its value is an instance then it will be parented to the instance.
- **Example** - **Example**
Basic element creation. Basic element creation.
```lua ```luau
local frame = create "Frame" { local frame = create "TextButton" {
Name = "NewFrame", Name = "Button",
Position = UDim2.fromScale(1, 0) Size = UDim2.fromOffset(200, 160),
}
```
A component using property nesting. Activated = function()
print "clicked"
end,
```lua create "UICorner" {}
type Layout = {
Layout = {
Position: UDim2?,
Size: UDim2?,
AnchorPoint: Vector2?
} }
}
type Children = {
Children = Array<Instance>
}
function Background(props: Layout & Children & {
Color: Color3
})
return create "Frame" {
BackgroundColor3 = props.Color,
props.Layout,
props.Children
}
end
``` ```
## action() ## action()
Creates a callback that can be passed to `create()` to invoke custom actions on Creates a special object that can be passed to `create()` to invoke custom
instances. actions on instances.
- **Type** - **Type**
```lua ```luau
function action((Instance) -> (), priority: number = 1): Action function action((Instance) -> (), priority: number = 1): Action
``` ```
- **Details** - **Details**
When passed to `create()`, the given callback is called with the instance When passed to `create()`, the function is called with the instance being
being created as the only argument. Actions take precedence over property created as the only argument. Actions take precedence over property and
and child assignments. child assignments.
A priority can be optionally specified to ensure certain actions run after A priority can be optionally specified to ensure certain actions run after
other actions. Higher priority numbers are ran after lower priority numbers. other actions. Lower priority values are ran first.
- **Example** - **Example**
An action to listen to changed properties: An action to listen to changed properties:
```lua ```luau
local function changed(property: string, callback: (new) -> ()) local function changed(property: string, fn: (new) -> ())
return action(function(instance) return action(function(instance)
instance:GetPropertyChangedSignal("property"):Connect(function() local cn = instance:GetPropertyChangedSignal(property):Connect(function()
callback(instance[property]) fn(instance[property])
end)
-- disconnect on scope destruction to allow gc of instance
cleanup(function()
cn:Disconnect()
end) end)
end) end)
end end
@ -147,7 +95,58 @@ instances.
local output = source "" local output = source ""
create "TextBox" { create "TextBox" {
-- will update the `output` source anytime the text property is changed -- will update the output source anytime the text property is changed
changed("Text", output) changed("Text", output)
} }
``` ```
## changed()
A wrapper for `action()` to listen for property changes.
- **Type**
```luau
function changed(property: string, fn: (unknown) -> ()): Action
```
- **Details**
Will run the given function immediately and whenever the property updates.
The function is called with the updated property value.
Runs with an action priority of 1.
## mount() <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge>
Runs a function in a new stable scope and optionally applies its result to a
target instance.
- **Type**
```luau
function mount<T>(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)
```

View file

@ -1,132 +1,155 @@
# Reactivity API: Core # Reactivity: Core
<br/> ## Scopes
## root() Vide code can run in one of two scopes: <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge> or <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>.
Creates and runs a function in a new reactive scope. - Reactive scopes rerun if a source read within updates.
- Stable scopes never rerun.
- Reactive scopes cannot be created directly within another reactive scope.
- When a scope is destroyed, all scopes created within are also destroyed.
Different functions in Vide's API will run code in different scopes.
:::warning
Yielding is not allowed in any stable or reactive scope. Strict mode will check
for this.
:::
## root() <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge>
Runs a function in a new stable scope.
- **Type** - **Type**
```lua ```luau
function root<T...>(fn: (destroy: () -> ()) -> T...): T... function root<T...>(fn: (Destructor) -> T...): (Destructor, T...)
type Destructor = () -> ()
``` ```
- **Details** - **Details**
Creates a new root reactive scope, where creation and derivations of sources Returns a destructor and any values returned by the callback.
can be tracked and properly disposed of.
Returns the result of the given function.
A function to destroy the root is passed into the callback, which will run
any cleanups and allow derived sources created to garbage collect.
::: warning
`fn()` cannot yield.
:::
## source() ## source()
Creates a new source with the given value. Creates a new source.
- **Type** - **Type**
```lua ```luau
function source<T>(value: T): (T?) -> T function source<T>(value: T): Source<T>
type Source<T> =
() -> T -- get
& (T) -> () -- set
``` ```
- **Details** - **Details**
Calling the returned source with no argument will return its stored value, Call the returned source with no argument to read its value.
calling with an argument will set a new value. Call the returned source with an argument to set its value.
Reading from the source from within a reactive scope will cause changes
to that source to be tracked and anything depending on it to update.
- **Example** - **Example**
```lua ```luau
local count = source(0) local count = source(0)
print(count())-- 0
count() -- 0 count(count() + 1)
print(count()) -- 1
count(count() + 1) -- 1
``` ```
## effect() ## effect() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Runs a side-effect on source update. Runs a function in a new reactive scope.
- **Type** - **Type**
```lua ```luau
function effect(callback: () -> ()) function effect(fn: () -> ())
``` ```
- **Details** - **Details**
The callback is ran immediately. The function is ran once immediately.
Any time a source referenced in the callback is changed, the callback will
be reran.
::: warning
`callback()` cannot yield.
:::
- **Example** - **Example**
```lua ```luau
local num = source(1) local count = source(1)
effect(function() effect(function()
print(num()) print(count())
end) end)
-- prints 1 -- prints 1
num(num() + 1) count(2)
-- prints 2 -- prints 2
``` ```
## derive() ## derive() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Derives a new source from existing sources. Runs a function in a new reactive scope to compute a value for new source.
- **Type** - **Type**
```lua ```luau
function derive<T>(source: () -> T): () -> T function derive<T>(fn: () -> T): () -> T
``` ```
- **Details** - **Details**
The derived source will have its value recalculated when any source source Anytime the reactive scope reruns, the output source value is set to what is
it derives from is updated. returned.
Anytime its value is recalculated it is also cached, subsequent calls will The function is ran once immediately.
retun this cached value until it recalculates again.
Takes a callback that is immediately run to determine what sources are being
referenced.
::: warning
`source()` cannot yield.
:::
- **Example** - **Example**
```lua ```luau
local count = source(0) local count = source(0)
local text = derive(function() return `count: {count()}` end) local text = derive(function() return `count: {count()}` end)
text() -- "count: 0" print(text()) -- "count: 0"
count(1) count(1)
text() -- "count: 1" print(text()) -- "count: 1"
``` ```
-------------------------------------------------------------------------------- A `derive()` should be used instead of a pure function when you expect it to
be read multiple times between updates, because `derive()` will cache the
result to prevent recomputing it on every read.
::: code-group
```luau [Pure Function]
local count = source(0)
local text = function()
print "ran"
return `count: {count()}`
end
count(1)
print(text()) -- prints "ran" followed by "count: 1"
print(text()) -- prints "ran" followed by "count: 1"
```
```luau [Derived Source]
local count = source(0)
local text = derive(function() -- [!code highlight]
print "ran"
return `count: {count()}`
end) -- [!code highlight]
count(1) -- prints "ran"
print(text()) -- prints "count: 1"
print(text()) -- prints "count: 1"
```
:::

View file

@ -0,0 +1,229 @@
# Reactivity: Dynamic Scopes
Dynamic scopes are scopes that are created or destroyed in response to
source updates. Vide provides functions for some common use-cases for dynamic scopes.
## show() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Shows a component if the source is truthy. Optionally shows a fallback component
if the source is falsey.
- **Type**
```luau
function show<T>(source: () -> unknown, component: Constructor<T>): () -> T?
function show<T, U>(source: () -> unknown, component: Constructor<T>, fallback: () -> U): () -> T | U
type Constructor<T> = () -> (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() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Shows one of a set of components depending on a source and a mapping table.
- **Type**
```luau
function switch<K, V>(source: () -> K): (map: Map<K, Constructor<V>>): () -> V?
type Constructor<T> = () -> (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() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Shows a component for each index in a table.
- **Type**
```luau
function indexes<KI, VI, VO>(
source: () -> Map<KI, VI>,
constructor: (value: () -> VI, index: KI) -> (VO, number?)
): Array<VO>
```
- **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<Item>
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() <Badge type="tip" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">REACTIVE</a></Badge>
Shows a component for each value in a table.
- **Type**
```luau
function values<KI, VI, VO>(
source: () -> Map<KI, VI>,
constructor: (value: VI, index: () -> KI) -> (VO, number?)
): Array<VO>
- **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<Item>
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.
--------------------------------------------------------------------------------

View file

@ -1,218 +0,0 @@
# Reactivity API: Control Flow
<br/>
## show()
Shows one of two components depending on an input source.
- **Type**
```lua
function show<T>(source: () -> unknown, component: () -> T): () -> T?
function show<T, U>(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<K, V>(source: () -> K): (map: Map<K, () -> 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<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: () -> VI, index: KI) -> VO
): Array<VO>
- **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<Item>
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<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: VI, index: () -> KI) -> VO
): Array<VO>
- **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<Item>
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.
--------------------------------------------------------------------------------

View file

@ -1,47 +1,58 @@
# Reactivity API: Utility # Reactivity: Utility
## cleanup() ## cleanup()
Runs a callback anytime a reactive scope is re-ran. Queues a callback to run when a scope is reran or destroyed.
- **Type** - **Type**
```lua ```luau
function cleanup(callback: () -> ()) function cleanup(v: Function | Disconnectable | Destroyable | thread)
type Function = () -> ()
type Destroyable = { destroy: () -> () }
type Disconnectable = { disconnect: () -> () }
``` ```
- **Example** - **Example**
```lua ```luau
local data = source(1) local count = source(0)
local destroy = root(function()
effect(function() effect(function()
local label = create "TextLabel" { Text = data() } count()
cleanup(function() cleanup(function()
label:Destroy() print "cleaned"
end) end)
end) end)
end
-- nothing printed yet
count(1) -- prints "cleaned"
count(2) -- prints "cleaned"
destroy() -- prints "cleaned"
``` ```
## untrack() ## untrack() <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge>
Runs a given function where any sources read will not track its reactive scope. Runs a function in a new stable scope.
- **Type** - **Type**
```lua ```luau
function untrack<T>(source: () -> T): T function untrack<T>(source: () -> T): T
``` ```
- **Details** - **Details**
Updates made to a source passed to `untrack()` will not cause updates to Can be used inside a reactive scope to read from sources you do not want
anything depending on that source. tracked by the reactive scope.
- **Example** - **Example**
```lua ```luau
local a = source(0) local a = source(0)
local b = source(0) local b = source(0)
@ -50,9 +61,9 @@ Runs a given function where any sources read will not track its reactive scope.
end) end)
print(sum()) -- 0 print(sum()) -- 0
b(1) b(1) -- untracked so reactive scope created by derive() does not rerun
print(sum()) -- 0 print(sum()) -- 0
a(1) a(1) -- reactive scope created by derive() reruns
print(sum()) -- 2 print(sum()) -- 2
``` ```
@ -62,8 +73,86 @@ Utility used to read a value that is either a primitive or a source.
- **Type** - **Type**
```lua ```luau
function read<T>(value: T | () -> T): T function read<T>(value: T | () -> T): T
``` ```
-------------------------------------------------------------------------------- ## batch()
Runs a function where any source updates made within the function do not
trigger effects until after the function ends.
- **Type**
```luau
function batch(fn: () -> ())
```
- **Details**
Improves performance when an effect depends on multiple sources, and those
sources need to be updated.
- **Example**
```luau
local a = source(0)
local b = source(0)
effect(function()
print(a() + b())
end)
-- prints "0"
batch(function()
a(1) -- no print
b(2) -- no print
end)
-- prints "3"
```
## context() <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge>
Creates a new context.
- **Type**
```luau
function context<T>(default: T): Context<T>
type Context<T> =
() -> T -- get
& <U>(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)
```

View file

@ -2,7 +2,7 @@
Strict mode is library-wide and can get set by doing: Strict mode is library-wide and can get set by doing:
```lua ```luau
vide.strict = true vide.strict = true
``` ```
@ -14,24 +14,24 @@ and identifying improper usage.
Currently, strict mode will: Currently, strict mode will:
1. Run derived sources twice a source updates. 1. Run reactive scopes twice when a source updates.
2. Run effects twice when a source updates. 2. Throw an error if yields occur where they are not allowed.
3. Throw an error if yields occur where they are not allowed. 3. Checks for `indexes()` and `values()` outputting primitive values.
4. Checks for `indexes()` and `values()` returning primitive values. 4. Checks for `values()` input having duplicate values.
5. Checks for `values()` input having duplicate values. 5. Checks for duplicate nested properties at same depth.
6. Checks for duplicate nested properties at same depth. 6. Checks for destruction of an active scope.
7. Better error reporting and stack traces + creation traces of property bindings. 7. Better error reporting and stack traces.
By rerunning sources and effects, any side-effects are made more apparent. By rerunning reactive scopes twice each time they update, it helps ensure that
This also helps ensure that cleanups are being handled correctly. computations are pure, and that any cleanup is done correctly.
Accidental yielding within reactive scopes can break Vide's reactive graph, Accidental yielding within reactive scopes can break Vide's reactive graph,
which strict mode can catch. which strict mode will catch.
As well as additional safety checks, Vide will dedicate extra resources to As well as additional safety checks, Vide will dedicate extra resources to
recording and better emitting stack traces where errors occur, particularly recording and better emitting stack traces where errors occur, particularly
when binding properties to sources. when implicit effects are created for instance property updating.
It is recommend to develop UI with strict mode and to disable it when pushing to 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 don't production. In Roblox, production code compiles at O2 by default, so you do not
need to worry about disabling strict mode unless you have manually enabled it. need to worry about disabling strict mode unless you have manually enabled it.

View file

@ -1,14 +1,16 @@
--- ---
# https://vitepress.dev/reference/default-theme-home-page # https://vitepress.dev/reference/default-theme-home-page
layout: home layout: home
pageClass: home
next:
text: 'Introduction'
link: '/tut/crash-course/1-introduction'
hero: hero:
name: Vide name: "Vide"
text: ""
tagline: A reactive UI library for Luau. tagline: A reactive UI library for Luau.
image: image:
src: /logo.svg src: /logo.svg
alt: Vide
actions: actions:
- theme: brand - theme: brand
text: Tutorials text: Tutorials
@ -17,8 +19,4 @@ hero:
text: API Reference text: API Reference
link: /api/reactivity-core link: /api/reactivity-core
features:
- title: In Development
details: Not recommended for production use.
--- ---

View file

@ -1,13 +1,12 @@
{ {
"type": "module", "type": "module",
"scripts": { "scripts": {
"docs:dev": "vitepress dev", "docs:dev": "vitepress dev",
"docs:build": "vitepress build", "docs:build": "vitepress build",
"docs:preview": "vitepress preview" "docs:preview": "vitepress preview"
}, },
"devDependencies": { "devDependencies": {
"vitepress": "^1.0.0-rc.4" "vitepress": "^1.4.5",
"vitepress-plugin-mermaid": "2.0.17"
} }
} }

View file

@ -1,4 +1,4 @@
<svg width="150" height="88" viewBox="15 7 115 68" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="375" height="220" viewBox="15 7 115 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_1_41)"> <g filter="url(#filter0_d_1_41)">
<g clip-path="url(#clip0_1_41)"> <g clip-path="url(#clip0_1_41)">
<rect x="18" y="12" width="110" height="48" rx="8" fill="url(#paint0_radial_1_41)"/> <rect x="18" y="12" width="110" height="48" rx="8" fill="url(#paint0_radial_1_41)"/>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

View file

@ -1,37 +1,31 @@
<svg width="384" height="384" viewBox="8 8 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="200" height="200" viewBox="0 0 152 152" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_ddd_1_134)"> <g filter="url(#filter0_ii_282_2)">
<path d="M21.4019 35.5C22.5566 37.5 25.4434 37.5 26.5981 35.5L38.4546 14.9639C39.2786 13.5367 38.1196 11.7812 36.4834 11.9783L30.3548 12.7164C28.9029 12.8913 27.6622 13.8444 27.1191 15.2021L24 23L20 13L11.5166 11.9783C9.88038 11.7812 8.72139 13.5367 9.54541 14.9639L21.4019 35.5Z" fill="url(#paint0_radial_1_134)"/> <path d="M63.659 130.625C69.1438 140.125 82.8561 140.125 88.341 130.625L144.659 33.0785C148.573 26.2993 143.068 17.9607 135.296 18.8969L106.185 22.4029C99.2887 23.2337 93.3954 27.7609 90.8157 34.21L76 71.25L57 23.75L16.7038 18.8969C8.93178 17.9607 3.42658 26.2993 7.34067 33.0785L63.659 130.625Z" fill="url(#paint0_radial_282_2)"/>
<path d="M20 13L24 23L28 33.0718C27.1412 34.5593 24.9736 34.4973 24.2012 32.9632L13.7728 12.25L20 13Z" fill="url(#paint1_radial_1_134)"/> <path d="M57 23.75L76 71.25L95 119.091C90.9207 126.157 80.6246 125.862 76.9557 118.575L27.4208 20.1875L57 23.75Z" fill="url(#paint1_radial_282_2)"/>
<path d="M38.4546 14.9639L26.5981 35.5C25.4434 37.5 22.5566 37.5 21.4019 35.5L20 33.0718L16 26.1436C17.6267 28.9319 21.7492 28.627 22.9481 25.6298L24 23L28 13L36.4834 11.9783C38.1196 11.7812 39.2786 13.5367 38.4546 14.9639Z" fill="#4896F3"/> <path d="M144.659 33.0785L88.3409 130.625C82.8561 140.125 69.1438 140.125 63.659 130.625L57 119.091L38 86.1821C45.7268 99.4265 65.3087 97.9783 71.0034 83.7416L76 71.25L95 23.75L135.296 18.8969C143.068 17.9607 148.573 26.2993 144.659 33.0785Z" fill="#4896F3"/>
</g> </g>
<defs> <defs>
<filter id="filter0_ddd_1_134" x="-4.72681" y="-0.0369511" width="61.4536" height="57.037" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> <filter id="filter0_ii_282_2" x="6.04761" y="18.8246" width="163.905" height="166.925" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/> <feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="2" dy="4"/> <feOffset dx="24" dy="48"/>
<feGaussianBlur stdDeviation="8"/> <feGaussianBlur stdDeviation="96"/>
<feComposite in2="hardAlpha" operator="out"/> <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.2 0"/> <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_134"/> <feBlend mode="normal" in2="shape" result="effect1_innerShadow_282_2"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="1" dy="2"/> <feOffset dx="12" dy="24"/>
<feGaussianBlur stdDeviation="4"/> <feGaussianBlur stdDeviation="48"/>
<feComposite in2="hardAlpha" operator="out"/> <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.1 0"/> <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_1_134" result="effect2_dropShadow_1_134"/> <feBlend mode="normal" in2="effect1_innerShadow_282_2" result="effect2_innerShadow_282_2"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.12549 0 0 0 0 0.313726 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_1_134" result="effect3_dropShadow_1_134"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_1_134" result="shape"/>
</filter> </filter>
<radialGradient id="paint0_radial_1_134" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24 39.5) rotate(-90) scale(28 32.3316)"> <radialGradient id="paint0_radial_282_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(76 149.625) rotate(-90) scale(133 153.575)">
<stop stop-color="#24447F"/> <stop stop-color="#24447F"/>
<stop offset="1" stop-color="#3661A2"/> <stop offset="1" stop-color="#3661A2"/>
</radialGradient> </radialGradient>
<radialGradient id="paint1_radial_1_134" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(21 12) rotate(74.3578) scale(25.9615 15.2089)"> <radialGradient id="paint1_radial_282_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(61.75 19) rotate(74.3578) scale(123.317 72.2423)">
<stop stop-color="#1D314F"/> <stop stop-color="#1D314F"/>
<stop offset="1" stop-color="#0D1A2E"/> <stop offset="1" stop-color="#0D1A2E"/>
</radialGradient> </radialGradient>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

@ -0,0 +1,148 @@
# Dynamic Scopes
Dynamic scopes are scopes that are created and destroyed in response to
source updates. This is needed for conditionally rendering parts of your UI,
such as opening and closing menus.
While Vide provides functions for common ways to do this, this section will
show how you can implement them yourself so you are not limited by only what is
provided.
## Recreating [`show()`](/api/reactivity-dynamic#show-reactive)
The most basic one, `show()`, can be
implemented yourself like so:
```luau
local function show(toggle: () -> unknown, component: () -> Instance)
return derive(function()
return if toggle() then untrack(component) else nil
end)
end
```
The main thing to note here is the use of `untrack()`. This function runs its
callback in a new stable scope. Without this, if the component were to create
a reactive scope, an error would occur since a reactive scope cannot be created
within a reactive scope.
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#111720",
"primaryTextColor": "#fff",
"primaryBorderColor": "#444455",
"lineColor": "#79B8FF",
"tertiaryColor": "#0d131b",
"tertiaryBorderColor": "#444455"
}
}}%%
graph
subgraph derive ["derive (reactive)"]
subgraph untrack ["untrack (stable)"]
subgraph effect ["effect (reactive)"]
end
end
end
```
You can see from the above graph how the effect would not be created directly
inside the derive, there is a stable scope between them. This requirement exists
as a guard against unintentional rerendering of UI.
## Recreating [`switch()`](/api/reactivity-dynamic#switch-reactive)
```luau
local function switch(key)
return function(map)
return derive(function()
local component = map[key()]
return if component then untrack(component) else nil
end)
end
end
```
## Recreating [`indexes()`](/api/reactivity-dynamic#indexes-reactive)
This is a more complicated function because it manages multiple scopes at the
same time, unlike the previous functions. Because some scopes may persist
between reruns, we cannot use `untrack()` anymore which automatically destroys
on rerun; we must use `root()` where the lifetime of each scope is managed
manually and independently.
```luau
local function indexes<I, VI, VO>(
input: () -> Map<I, VI>,
transform: (value: () -> VI, index: I) -> VO
)
local index_caches = {} :: Map<I, {
previous_input: VI,
output: VO,
source: (VI) -> 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.

View file

@ -1,61 +0,0 @@
# Reactive Scoping
This is a brief document designed to give the user more insight into how Vide's
reactive graph works.
Each time you create and derive sources, a new node representing that source is
created and added to the reactive graph. Each node stores a value and a
side-effect function. Each node also keeps track of its parents and children,
as well as any cleanups registered.
Any time a node is updated, Vide will traverse and update that node's children,
its children's children, etc, until all nodes descending from that node has been
updated. Traversal will stop at a node if that node's cached value does not
change after an update.
For every node that is updated, a scope is opened for that node. These scopes
are referred to as "reactive scopes". Any source read from within a node's scope
will that node as a child. This is similar to cleanups, anytime a cleanup is
registered, it is added to the node of the currently active scope.
The way Vide tracks reactive scopes, is by using a stack of nodes. The current
active reactive scope is the node at the top of this stack.
When destroying a node, its descendents are traversed and also destroyed.
When being destroyed, a node's connections (parents and children) are cleared,
and any pending cleanup functions are ran.
The purpose of `root()` (which is called internally by `mount()`) is to setup
the root node which will track any node created or derived inside its scope, or
any cleanups registered. Without it, nodes could be garbage collected without a
chance to run pending cleanups which can cause memory leakage.
Control flow functions in Vide are special, as they can dynamically create and
destroy new root scopes.
It is the combination of the above which allows us to write components like so:
```lua
local function Counter()
local count = source(0)
local connection = stepped:Connect(function() count(count() + 1) end)
cleanup(function() connection:Disconnect() end)
effect(function() print(count()) end)
return create "TextLabel" { Text = count }
end
```
Vide doesn't recognise this as a "component", that is a user abstraction. Vide
just sees this as a function that creates nodes in the reactive graph.
Whenever the reactive scope that calls this function is destroyed, like by a
control flow function, the registered cleanup will be called, and the effect
(which is just a node on the reactive graph) is destroyed. The returned instance
and the bound `count` source is just considered to be a side-effect, and with
the reactive scope from which the side-effects stem from destroyed, the instance
can be garbage collected - everything is nicely cleaned up.
> todo: add graphics

View file

@ -1 +0,0 @@
# show()

View file

@ -1 +0,0 @@
# switch()

View file

@ -1 +0,0 @@
# indexes()

View file

@ -1 +0,0 @@
# values()

View file

@ -1,42 +1,21 @@
# Introduction # Introduction
This is a brief tutorial designed to give you a quick run through the usage of This is a tutorial that introduces the concepts and usage of Vide.
Vide.
Vide is heavily inspired by [Solid](https://www.solidjs.com/). Vide is heavily inspired by [Solid](https://www.solidjs.com/).
## Why Vide? ## Why Vide?
Creating UI is a slow and tedious process. The purpose of Vide is to make UI Vide's reactive and declarative API aims to let you program UI as simply as
declarative and concise, making it faster to create and more importantly easier possible, with a strong focus on how data flows through your application.
to maintain. Vide achieves this using a reactive style of programming which
allows you to focus on the flow of data through your application without
worrying about manually updating UI instances.
Some of the main focuses behind Vide's design choices: Some of Vide's main design choices:
- Concise syntax to reduce verbosity as much as possible. - Syntax minimal.
- Being completely typecheckable. - Data oriented.
- Independence from instance lifetimes. - Typechecking compatible.
- A powerful reactive system that can update specific properties as a result of - Instance independent.
state changes, updates are immediate with no diffing needed.
## Structure Of A Vide App Vide's reactivity operates with the concept
of scopes which carries a learning curve, though is what makes Vide's minimal
The entry point for all Vide apps is the `mount()` function. This function syntax possible. The crash course will introduce these concepts gradually.
sets up Vide's reactivity system. It takes and calls a function that should
create your entire app, and will apply its result to a target.
In Vide, your app should be composed of functions, each function creates a
specific part of your app, and can be reused if needed. These functions are
called *components*.
```lua
local function App()
return create "ScreenGui" {
create "TextLabel" { Text = "hi" }
}
end
mount(App, game.StarterGui)
```

View file

@ -0,0 +1,38 @@
# Cleanup
Sometimes you may need to do some cleanup when destroying a component or after
a side-effect from a source update. Vide provides a function `cleanup()` which
is used to queue a callback for the next time a reactive scope is rerun or
destroyed, or when a stable scope is destroyed.
```luau
local root = vide.root
local source = vide.source
local effect = vide.effect
local cleanup = vide.cleanup
local count = source(0)
local destroy = root(function()
effect(function()
local x = count()
cleanup(function() print(x) end)
end)
cleanup(function() print "root destroyed" end)
end)
count(1) -- prints "0"
count(2) -- prints "1"
destroy() -- prints "2" and "root destroyed"
```
::: tip
Roblox instances do not need to be explicitly destroyed for their
memory to be freed, they only need to be parented to `nil`. So there is no
need to use `cleanup()` to destroy instances. However, be wary of connecting
a function that references an instance to an event from the same instance,
this causes the instance to reference itself and never be freed. In such a case
you would need to use `cleanup()` to disconnect this connection or to explicitly
destroy the instance.
:::

View file

@ -1,120 +0,0 @@
# Nested Properties
Often when creating components from existing components, you can find yourself
repetitively passing through properties such as size or position.
Example below:
```lua
function Background(props: {
Color: Color3,
AnchorPoint: UDim2,
Position: UDim2,
Size: UDim2
})
return create "Frame" {
Color = props.Color
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
}
end
function Menu(props: {
Color = props.Color
AnchorPoint: UDim2,
Position: UDim2,
Size: UDim2
})
return Background {
Color = props.COlor,
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
}
end
```
One way this can be avoided is by using *property nesting*. In Vide, passing a
table value inside `props` has special semantics. Any key with a table value is
not assigned like a property, instead the table is iterated and processed just
like the outer table is. Any properties in the nested table will be assigned
to the instance just the same.
Below is an example of how you can use this to pass groups of similar properties
together such as position and size, while also using typechecking.
```lua
type Layout = {
Layout = {
Position: UDim2?,
Size: UDim2?,
AnchorPoint: Vector2?
}
}
function Background(props: Layout & { Color: Color3 })
return create "Frame" {
Color = props.Color,
props.Layout
}
end
function Menu(props: Layout & { Color: Color3 })
return Background {
Color = props.Color,
Layout = props.Layout
}
end
```
Here we created a nested group with the key `Layout` that can accept
layout-related properties. Any name could be chosen for the key.
This allows us to write much more concise syntax that is also typecheckable.
In another example we use a key named `Children` to pass arrays of instances to
be parented.
```lua
type Children = {
-- allows us to also optionally pass a source that returns an array of children instead
Children = Array<Instance> | () -> Array<Instance>
}
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
```

View file

@ -0,0 +1,211 @@
# Dynamic Scopes
Eventually you may need a way to dynamically create and destroy UI elements
resulting from source updates. Vide provides functions to help you do this,
known as *dynamic scope* functions.
These functions create and destroy scopes for you in response to source
updates. They return a source containing the created component. This source can
be parented as a child which will update the shown children whenever the source
updates.
The simplest example is using `show()`.
```luau
local source = vide.source
local create = vide.create
local show = vide.show
local root = vide.root
function Button(props: { Text: string, Activated: () -> () })
return create "TextButton" {
Text = props.Text,
Activated = props.Activated
}
end
function Menu()
return create "TextLabel" {
Text = "This is a menu"
}
end
function App()
local toggled = source(false)
return create "ScreenGui" {
Button {
Text = "Toggle Menu",
Activated = function()
toggled(not toggled())
end
},
show(toggled, Menu) -- [!code highlight]
}
end
root(function()
App().Parent = game.StarterGui
end)
```
This is a complete example of rendering UI which has a single button that
toggles the opening of a menu.
--------------------------------------------------------------------------------
Another common function is `indexes()`. This function creates a component for
each index in a table.
Each component created is done so in a new and independent stable scope. The
indexes of the table are checked each source update to prevent redunant
destruction and recreation of UI elements.
```luau
local source = vide.source
local create = vide.create
local indexes = vide.indexes
local root = vide.root
local function Todo(props: {
Text: () -> string,
Position: number,
Activated: () -> ()
})
return create "TextButton" {
Text = function() return props.Position .. ": " .. props.Text() end,
LayoutOrder = props.Position,
Activated = Activated
}
end
local function TodoList(props: { List: () -> Array<string> })
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.

View file

@ -1,14 +1,11 @@
# Actions # Actions
Actions in Vide are special callbacks that you can pass along with properties, Actions are special callbacks that you can pass along with properties,
which will be called when those properties are being processed with the instance to run some code on an instance receiving them.
being assigned to, allowing you to run custom code.
```lua ```luau
local action = vide.action local action = vide.action
```
```lua
create "TextLabel" { create "TextLabel" {
Text = "test", Text = "test",
@ -20,24 +17,24 @@ create "TextLabel" {
-- will print "test" -- will print "test"
``` ```
Actions can be wrapped with functions to re-use specific behaviors. Below is Actions can be wrapped with functions for reuse. Below is an example of an
an example of an action used to listen for property changes: action used to listen for property changes:
```lua ```luau
local action = vide.action local action = vide.action
local source = vide.source
local effect = vide.effect
local cleanup = vide.cleanup local cleanup = vide.cleanup
local function changed(property: string, callback: (new) -> ()) local function changed(property: string, callback: (new) -> ())
return action(function(instance) return action(function(instance)
local con = instance:GetPropertyChangedSignal(property):Connect(function() local connection = instance:GetPropertyChangedSignal(property):Connect(function()
callback(instance[property]) callback(instance[property])
end) end)
-- remember to clean up the connection when the reactive scope the action -- remember to clean up the connection when the reactive scope the action
-- is ran in is destroyed, so the instance can be garbage collected -- is ran in is destroyed, so the instance can be garbage collected
cleanup(function() cleanup(connection)
con:Disconnect()
end)
end) end)
end end
@ -47,9 +44,11 @@ local instance = create "TextBox" {
changed("Text", output) changed("Text", output)
} }
instance.Text = "foo" effect(function()
print(output())
end)
print(output()) -- "foo" instance.Text = "foo" -- "foo" will be printed by the effect
``` ```
The source `output` will be updated with the new property value any time it is The source `output` will be updated with the new property value any time it is

View file

@ -5,11 +5,15 @@ be set with `vide.strict = true` once when you first require Vide. Strict mode
will add extra safety checks and emit better error traces, particularly when will add extra safety checks and emit better error traces, particularly when
errors occur in property bindings. errors occur in property bindings.
Strict mode will run derived sources and effects twice each time they update. Strict mode is automatically enabled when Vide is required in O0 or O1
This is to help identify improper cleanup of side-effects and ensure that pure optimization (default studio level). You can `vide.strict = false` if you do not
computations are actually pure. want this.
```lua Strict mode will run derived sources and effects twice each time they update.
This is to help ensure that derived source computations are pure, and that any
cleanups made in derived sources or effects are done properly.
```luau
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
@ -19,6 +23,7 @@ local count = source(0)
local ran = 0 local ran = 0
effect(function() effect(function()
count()
ran += 1 ran += 1
end) end)

View file

@ -0,0 +1,113 @@
# Concepts Summary
A summary of all the concepts covered during the crash course.
## Source
A source of data.
Stores a single value that can be updated.
Created with `source()`.
## Derived Source
A new source composed of other sources.
Created with a plain function or with `derive()`.
## Effect
Anything that happens in response to a source update.
Created with `effect()`.
## Stable Scope
One of the two types of Vide scopes.
Created by:
- `root()`
- `untrack()`
- `show()`
- `indexes()`
Stable scopes do not track sources and never rerun.
New stable or reactive scopes can be created within a stable scope.
## Reactive Scope
Created by:
- `effect()`
- `derive()`
Reactive scopes do track sources and will rerun when those sources update.
Reactive scopes cannot be created within a reactive scope, but stable scopes
can be created within a reactive scope.
## Scope Cleanup
When a scope is rerun or destroyed, all scopes created within it are
automatically destroyed.
Any functions queued by `cleanup()` are also ran.
## Reactive Graph
The combination of stable and reactive scopes can viewed graphically, called a
*reactive graph*. This can be a more intuitive way to think of the
relationships between effects and the sources they depend on.
### Code
```luau
local count = source(0)
root(function()
local text = derive(function()
return "count: " .. count()
end)
effect(function()
print(text())
end)
end)
```
### Graph resulting from code
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#111720",
"primaryTextColor": "#fff",
"primaryBorderColor": "#111720",
"lineColor": "#79B8FF",
"tertiaryColor": "#0d131b",
"tertiaryBorderColor": "#202530"
}
}}%%
graph LR
subgraph root
text --> effect
end
count --> text
```
Notes:
- Since `count` is a source, not an effect, it can exist
outside of scopes.
- An update to `count` will cause `text` to rerun, which
then causes `effect` to rerun.
- When the root scope is destroyed, `text` and
`effect` will be destroyed alongside it, since they were created within it.
`count` will be untouched and future updates to `count` will have no effect.

View file

@ -2,18 +2,13 @@
Instances are created using `create()`. Instances are created using `create()`.
`create()` returns a constructor for a class which then takes a table of Parentheses `()` can be omitted when calling functions with string or
properties to assign when creating a new instance for that class. table literals for brevity.
Luau allows us to omit parentheses `()` when calling functions with string or ```luau
table literals which Vide takes advantage of for brevity.
```lua
local mount = vide.mount
local create = vide.create local create = vide.create
local function App() return create "ScreenGui" {
return create "ScreenGui" {
create "Frame" { create "Frame" {
AnchorPoint = Vector2.new(0.5, 0.5), AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
@ -35,29 +30,9 @@ local function App()
end end
} }
} }
} }
end
mount(App, game.StarterGui)
``` ```
Assign a value to a string key to set a property, and assign a value to a 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 number key to set a child. Events can be connected to by assigning a function
to a string key. to a string key.
You can also use a shorthand to create datatypes instead of explicitly typing
out the class name and constructor. The table will be unpacked into the `.new()`
constructor of the property's type.
```lua
create "Frame" {
AnchorPoint = { 0.5, 1 },
UDim2 = { 0.5, 0, 0.5, 0 }
}
```
When creating an instance with no properties, it is important to not forget to
actually call the constructor: `create "Frame" {}` and not `create "Frame"`.
To be clear, `create "Frame"` returns a *function* which is a constructor for
that class, not an instance of that class. This would result in you attempting
to parent a function instead of an instance which is not the correct behavior.

View file

@ -1,11 +1,16 @@
# Components # Components
Components are custom-made reusable pieces of UI made from other pieces of UI. Vide encourages separating different parts of your UI into functions called
*components*.
By using components you can make your application more modular and better A component is a function that creates and returns a piece of UI.
organized.
```lua [Button.luau] This is a way to separate your UI into small chunks that you can reuse and put
together.
::: code-group
```luau [Button.luau]
local create = vide.create local create = vide.create
local function Button(props: { local function Button(props: {
@ -15,50 +20,46 @@ local function Button(props: {
}) })
return create "TextButton" { return create "TextButton" {
BackgroundColor3 = Color3.fromRGB(50, 50, 50), BackgroundColor3 = Color3.fromRGB(50, 50, 50),
TextColor3 = Color3.fromRGB(255, 255, 255),
Size = UDim2.fromOffset(200, 150), Size = UDim2.fromOffset(200, 150),
Position = props.Position, Position = props.Position,
Text = props.Text, Text = props.Text,
Activated = props.Activated Activated = props.Activated,
create "UICorner" {}
} }
end end
return Button return Button
``` ```
```lua [App.luau] ```luau [Menu.luau]
local mount = vide.mount
local create = vide.create local create = vide.create
local Button = require(Button) local Button = require(Button)
local function App() local function Menu()
return create "ScreenGui" { return create "ScreenGui" {
Button { Button {
Position = UDim2.fromOffset(200, 200), Position = UDim2.fromOffset(200, 200),
Text = "click me!", Text = "back",
Activated = function() Activated = function()
print "clicked" print "go to previous page"
end
},
Button {
Position = UDim2.fromOffset(400, 200),
Text = "next",
Activated = function()
print "go to next page"
end end
} }
} }
end end
mount(App, game.StarterGui)
``` ```
Above is a simple example of a button component with a set color and size, :::
being reused across files.
A single parameter `props` is used to pass properties to the component. A single parameter `props` is used to pass properties to the component.
Components allow you to *encapsulate* behavior. You can only modify the
component in ways that you allow in the component, through the `props` parameter.
To create a new button all you must do is call the `Button` function, passing in
values through props. This saves having to create and set every property each
time. Also, when updating the button component in future, any changes to the
button file will be seen anywhere the button is used in your app.
This can be extended to much more complicated UI.

View file

@ -1,12 +1,11 @@
# Source # Sources
*Sources* in Vide are special objects that store a single value. They are the Sources are special objects that store a single value and are the core of
core of reactivity in Vide. Each source represents a source of data, and they Vide's reactivity.
can be composed and derived to create new sources of data.
A source in Vide can be created using `source()`. A source can be created using `source()`.
```lua ```luau
local source = vide.source local source = vide.source
local count = source(0) local count = source(0)
@ -17,14 +16,13 @@ The value passed to `source()` is the initial value of the source.
The value of a source can be set by calling it with an argument, and can be read The value of a source can be set by calling it with an argument, and can be read
by calling it with no arguments. by calling it with no arguments.
```lua ```luau
count(count() + 1) -- increment count by 1 count(count() + 1) -- increment count by 1
``` ```
Sources can be *derived* by wrapping them in functions. A wrapped source Sources can be *derived* by wrapping them in functions.
effectively becomes a new source.
```lua ```luau
local count = source(0) local count = source(0)
local text = function() local text = function()
@ -36,8 +34,5 @@ count(1)
print(text()) -- "count: 1" print(text()) -- "count: 1"
``` ```
You may be wondering why we are using sources instead of plain variables to do While the above can be achieved with plain variables, the use for sources will
this. The reason is that Vide has an entire reactive system based on sources. be obvious in the next part.
You can write functions to automatically run each time a source is updated. This
can be to update properties, create new instances, print to the terminal, etc.
How this is done will be covered next.

View file

@ -1,11 +1,11 @@
# Effect # Effects
Effects are functions that are ran in response to source updates. They are Effects are functions that are ran in response to source updates.
alled effects because they cause *side-effects* when reacting to source updates. A source and effect is analogous to a signal and connection.
Effects are created using `effect()`. Effects are created using `effect()`.
```lua ```luau
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
@ -20,17 +20,16 @@ count(1)
-- "count: 1" printed -- "count: 1" printed
``` ```
The callback given to `effect()` is ran in a *reactive-scope*. Any source read Any source read inside an effect is tracked and will rerun the effect when
from inside a reactive scope will be tracked, so that if any of those sources that source is updated.
update, the effect will be re-ran too.
The callback is first ran immediately inside the `effect()` call to initially The effect runs its callback once immediately to initially figure out what
figure out what sources are being used. sources are being read.
Effects also work with derived sources, it doesn't matter how deeply nested a Derived sources are also tracked, it does not matter how deeply nested
source is. inside a function a source is.
```lua ```luau
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
@ -49,27 +48,24 @@ count(2)
-- "doubled count: 4" printed -- "doubled count: 4" printed
``` ```
Derived sources should be a *pure computation*. A pure computation is one where If a source is updated with the same value it already had, it will not rerun
the same input will always produce the same output. effects depending on it.
All observable changes to the user are considered to be side-effects of pure You can also read from a source within an effect without the effect tracking it.
computations.
Sources, derived sources, and effects form what is called a *reactive graph*. ```luau
In the above example a graph `count -> doubled -> effect` is formed. Anywhere local source = vide.source
an update occures, everything further down the graph is updated. local effect = vide.effect
local untrack = vide.untrack
You should not update other sources using an effect. Improper usage can lead to local a = source(0)
a cyclic loop in the graph, causing an infinite loop when it tries to update. local b = source(0)
Sources should be derived instead.
## Root Reactive Scopes effect(function()
print(`a: {a()} b: {untrack(b)}`)
end)
Effects must be created within another reactive scope. This is so that the a(1) -- prints "a: 1 b: 0"
effect itself can be tracked and later freed when the parent reactive scope is b(1) -- prints nothing
destroyed, such as from unmounting an app. The example code above will not a(2) -- prints "a: 2 b: 1"
actually work unless it is ran inside a root reactive scope, such as one created ```
by `vide.mount(function)`. This generally isn't a concern since you can assume
that all your components will be created within a single `mount()` call, which
happens only once at the top level, where you put together your UI and parent it
to a ScreenGUI.

View file

@ -0,0 +1,88 @@
# Scopes
Just like how a signal's connection may need to be disconnected, a source's
effect also may need to be disconnected.
But the disconnecting of many signals and connections is tedious and verbose.
Vide instead operates on the concept of scopes which provides a much cleaner
API, given that you follow a few rules.
There are two types of scopes: stable and reactive.
- A scope must be created within another scope.
- Stable scopes never rerun.
- Reactive scopes can rerun.
- A reactive scope cannot be created within another reactive scope, only within
a stable scope.
An exception to the first rule is `root()`, which creates the initial scope that
you destroy manually with a destructor function it returns.
`root()` creates a stable scope.
`effect()` creates a reactive scope.
Whenever a scope is destroyed, any scope created within that scope is also
destroyed, and so on.
```luau
local root = vide.root
local source = vide.source
local effect = vide.effect
local count = source(0)
local function setup()
effect(function()
print(count())
end)
end
setup() -- error, effect() tried to create a reactive scope with no stable scope
local destroy = root(setup) -- ok since effect() was called in a stable scope
count(1) -- prints "1"
count(2) -- prints "2"
destroy()
count(3) -- reactive scope created by effect() is destroyed, it does not rerun
```
Vide's reactivity can be represented graphically, as a *reactive graph*.
The reactive graph for the above example looks like so:
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#111720",
"primaryTextColor": "#fff",
"primaryBorderColor": "#111720",
"lineColor": "#79B8FF",
"tertiaryColor": "#0d131b",
"tertiaryBorderColor": "#0d131b"
}
}}%%
graph
subgraph root
direction LR
count --> effect
end
```
When the stable `root()` scope is destroyed, the reactive `effect()`
scope will also be destroyed since it was created within it.
This is important because you may have an effect that updates the property of a
UI instance, meaning the effect is referencing and holding that instance in
memory. The effect being destroyed will remove this reference, allowing the
instance to be garbage collected.
You don't need to worry about ensuring all your effects are created within a
stable scope, since you should be creating all your UI and effects within a
single top-level `root()` call that puts all your UI together, making it safe to
assume any effect created will be created under this stable scope.

View file

@ -1,64 +0,0 @@
# Property Binding
Explicitly creating effects to update properties can become verbose when there
are a lot of properties to update. Vide provides a way to *implicitly* create
an effect to update properties on source update. This is also known as
*property binding*, as a property is binded to reflect some data.
```lua
local create = vide.create
local source = vide.source
local function Counter()
local count = source(0)
return create "TextButton" {
Text = function()
return "count: " .. count()
end,
Activated = function()
count(count() + 1)
end
}
```
This example is equivalent to the example seen on the previous page.
Instead of explicitly creating an effect, assigning a (non-event) property
a function will implicitly create a side-effect to update that property anytime
a dependent source is updated.
Just like effects, the function is ran immediately in a reactive-scope to set
the property initially and determine what sources are being depended on.
This allows you as the programmer to not need to manually update UI as the state
of your program changes. You just define how the data maps to UI, and Vide's
reactive system will automatically update any properties depending on sources
that are updated.
## Children Binding
Children can also be set in a similar manner.
```lua
local items = source {
create "TextLabel" { Text = "A" }
}
local function List(props: { children: () -> { Instance } })
return create "Frame" {
create "UIListLayout" {},
props.children
}
end
local list = List { children = items } -- creates a list with a single text label "A"
items {
create "TextLabel" { Text = "B" },
create "TextLabel" { Text = "C" }
}
-- this will automatically unparent the text label "A", and parent the labels "B" and "C".
```

View file

@ -1,11 +1,9 @@
# Stateful Component # Reactive Components
A stateful component is a component that stores and displays some data. Reactive components in Vide are created using sources and effects - sources to
Stateful components in Vide are created using sources and effects - sources to
store the data, and effects to display the data. store the data, and effects to display the data.
```lua ```luau
local create = vide.create local create = vide.create
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
@ -23,7 +21,7 @@ local function Counter()
instance.Text = "count: " .. count() instance.Text = "count: " .. count()
end) end)
return count return instance
end end
``` ```
@ -31,36 +29,32 @@ Above is an example of a counter component, that when clicked, will increment
its internal count, and automatically update its text to reflect that count. its internal count, and automatically update its text to reflect that count.
Each instance of `Counter()` will maintain its own independent count, since the Each instance of `Counter()` will maintain its own independent count, since the
count source is created inside the scope of the component. count source is created inside the component.
External sources can also be passed into components for them to use. External sources can also be passed into components for them to use.
```lua ```luau
local function Counter(props: { count: () -> number }) local function CountDisplay(props: { count: () -> number })
local count = props.count local count = props.count
local instance = create "TextButton" { local instance = create "TextLabel" {}
Activated = function()
count(count() + 1)
end
}
effect(function() effect(function()
instance.Text = "count: " .. count() instance.Text = "count: " .. count()
end) end)
return count return instance
end end
local count = source(0) local count = source(0)
Counter { CountDisplay {
count = count count = count
} }
count(1) -- the Counter component will update to display this count count(1) -- the CountDisplay component will update to display this count
``` ```
Sources can be created internally or passed in from externally, there are no Sources can be created internally or passed in from externally, there are no
restrictions on how they are used as long as the effect is created within a restrictions on how they are used as long as the effect using it is created
reactive scope so that it can be tracked. within a stable scope.

View file

@ -1,48 +0,0 @@
# Cleanup
Sometimes you may need to do some cleanup when destroying a component or after
a side-effect from a source update. Vide provides a function `cleanup()` which
is used to register a cleanup callback for the next time the reactive scope
it is called in re-runs.
```lua
locla mount = vide.mount
local source = vide.source
local cleanup = vide.cleanup
local function Timer()
local count = source(0)
local con = game:GetService("RunService").Heartbeat:Connect(function(dt)
count(count() + dt)
end)
cleanup(function()
con:Disconnect()
end)
return create "TextButton" {
Position = UDim2.fromOffset(300, 300),
Size = UDim2.fromOffset(200, 50),
Text = function()
return "seconds: " .. math.floor(count())
end,
}
end
local unmount = mount(Timer)
unmount() -- all registered cleanups are ran, heartbeat connection stopped
```
In the above example, this allows us to disconnect the heartbeat connection
when the timer component is destroyed, whether that is from unmounting the app
or if it is dynamically created by a control-flow function, which will be
covered next.
On a related note: the reason why `mount()` is used to create your app, is so
that any top-level components that need to be cleaned up, can be cleaned up
when the app is later unmounted, since `mount()` runs in a reactive-scope to
track `cleanup()` calls. Vide's entire reactive system is independent from the
life-time of instances; instances are just a side-effect of the reactive system.

View file

@ -0,0 +1,83 @@
# Implicit Effects
Explicitly creating effects to update properties is tedious. You can
*implicitly* create an effect to update properties instead.
::: code-group
```luau [Implicit Effect]
local create = vide.create
local source = vide.source
local function Counter()
local count = source(0)
return create "TextButton" {
Activated = function()
count(count() + 1)
end,
Text = function()
return "count: " .. count()
end
}
end
```
```luau [Explicit Effect]
local create = vide.create
local source = vide.source
local effect = vide.effect
local function Counter()
local count = source(0)
local instance = create "TextButton" {
Activated = function()
count(count() + 1)
end
}
effect(function()
instance.Text = "count: " .. count()
end)
return instance
end
```
:::
This example is equivalent to the example seen on the previous page.
Instead of explicitly creating an effect, assigning a (non-event) property a
function will implicitly create an effect to update that property.
## Children
Children can also be set in a similar manner. A source passed as a child (passed
with a number key instead of string key) can return an instance or an array of
instances. An effect is automatically created to unparent removed instances and
parent new instances on source update.
```luau
local items = source {
create "TextLabel" { Text = "A" }
}
local function List(props: { children: () -> { Instance } })
return create "Frame" {
create "UIListLayout" {},
props.children
}
end
local list = List { children = items } -- creates a list with text label "A"
items {
create "TextLabel" { Text = "B" },
create "TextLabel" { Text = "C" }
}
-- this will automatically unparent text label "A", and parent labels "B" and "C"
```

View file

@ -1,157 +0,0 @@
# Control Flow
Eventually you will need a way to dynamically create and destroy UI elements
resulting from source updates. Vide provides functions to help you do this,
known as *control flow* functions.
These functions return new sources, which hold the instances to be displayed.
These sources can be assigned as children, meaning the displayed children
will update when the input source updates.
Control flow functions are special, because they run their components in a new
reactive scope, which can be destroyed independently of the reactive scope that
called the control flow function itself. This means that parts of your app can
be independently created then destroyed and cleaned.
## show()
The most basic control flow function is `show()`, which is used to conditionally
show a component.
```lua
local source = vide.source
local show = vide.show
local function JoinMenu()
local joined = source(false)
local function JoinButton()
return Button {
Activated = function() joined(true) end
}
end
return create "Frame" {
show(function() return not joined() end, JoinButton)
}
end
```
This will make a button to join if you have not joined already.
You can also pass a third argument, a fallback to show if the condition is falsey.
```lua
local function JoinMenu()
local joined = source(false)
local function JoinButton()
return Button {
Activated = function() joined(true) end
}
end
local function LeaveButton()
return Button {
Activated = function() joined(false) end
}
end
return create "Frame" {
show(joined, LeaveButton, JoinButton)
}
end
```
## switch()
Similar to `show()`, `switch()`, also condtionally displays one instance at a
time. It is more flexible since it can show one of many components, based on a
table used to map a source value to a component.
```lua
local source = vide.source
local switch = vide.switch
local function JoinMenu()
local joined = source(false)
local function JoinButton()
return Button {
Activated = function() joined(true) end
}
end
local function LeaveButton()
return Button {
Activated = function() joined(false) end
}
end
return create "Frame" {
switch(joined) {
[true] = LeaveButton,
[false] = JoinButton
}
}
end
```
This example is equivalent to the previous one.
The switch can map any value to any component.
```lua
type ActiveMenu = "none" | "inventory" | "shop" | "settings"
local menu = source "none"
switch(menu) {
inventory = InventoryMenu,
shop = ShopMenu.
settings = SettingsMenu
}
```
## indexes()
Often, you will have a table of values that will be displayed in a similar
manner. Rather than manually looping over each value to generate a corresponding
UI element, `indexes()` allows you to create an instance for each table index,
to display the value at that index.
```lua
local todoList = {
"finish the crash course",
"star vide's GitHub"
}
local function TodoList(props: { list: () -> Array<string> })
return create "Frame" {
create "UIListLayout" {},
indexes(todoList, function(todo, i)
return create "TextLabel" {
Text = function()
return i .. ": " .. todo()
end,
LayoutOrder = i
}
end)
}
end
TodoList { list = todoList }
```
For each unique index in the passed table, the transform function will be called
with 1. a source containing the value of the index, 2. the index itself.
When the value at an index is changed, the function is not reran. Instead, the
given source for that index is updated.
An element is only destroyed if the value of an index is set to `nil`.
Together, these control flow functions cover the majority of cases where you
need to dynamically create and destroy parts of your UI.

View file

@ -0,0 +1,93 @@
# Derived Sources
We have seen the basic way to derive a source:
```luau
local count = source(0)
local text = function()
return "count: " .. tostring(count())
end
print(text()) -- "count: 0"
count(1)
print(text()) -- "count: 1"
```
However, in some cases where this source could be used by multiple effects at
the same time, the function wrapping the source will needlessly rerun to convert
the count into a string for each effect using it.
```luau
local source = vide.source
local effect = vide.effect
local count = source(0)
local text = function()
print "ran"
return "count: " .. tostring(count())
end
effect(function() text() end)
effect(function() text() end)
count(1) -- prints "ran" x2
```
To avoid this, you can use `derive()` to derive a new source instead. This will
run a function in a reactive scope only when a source used inside updated.
Reading this derived source multiple times will just return a cached result.
```luau
local source = vide.source
local effect = vide.effect
local derive = vide.derive
local count = source(0)
local text = derive(function()
print "ran"
return "count: " .. tostring(count())
end)
effect(function() text() end)
effect(function() text() end)
count(1) -- prints "ran" x1
```
Because `derive()` creates a reactive scope, it must be called within a stable
scope, just like `effect()`.
If the recalculated value is the same as the old value, the derived source will
not rerun the effects using it.
The reactive graph for the above example:
```mermaid
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#111720",
"primaryTextColor": "#fff",
"primaryBorderColor": "#111720",
"lineColor": "#79B8FF",
"tertiaryColor": "#0d131b",
"tertiaryBorderColor": "#0d131b"
}
}}%%
graph
subgraph root
direction LR
count --> text --> effect1 & effect2
end
```
Deriving a source in this manner is similar to creating an effect to update
another source. You should avoid doing this using an effect however.
Improper usage could accidently create infinite loops in the reactive graph.
Always favour deriving when you need one source to update based on another
source.

10
init.luau Normal file
View file

@ -0,0 +1,10 @@
local vide = require "@self/src/lib"
export type source<T> = vide.source<T>
export type Source<T> = vide.Source<T>
export type context<T> = vide.context<T>
export type Context<T> = vide.Context<T>
export type Instances = vide.Instances
export type Properties<T> = vide.Properties<T>
return vide

15
pesde.toml Normal file
View file

@ -0,0 +1,15 @@
name = "centau/vide"
version = "0.4.1"
description = "A reactive Luau library for creating UI."
authors = ["centau"]
repository = "https://github.com/centau/vide"
license = "MIT"
includes = ["src/*", "README.md", "pesde.toml"]
[target]
environment = "roblox"
build_files = ["src"]
lib = "src/init.luau"
[indices]
default = "https://github.com/pesde-pkg/index"

9
rokit.toml Normal file
View file

@ -0,0 +1,9 @@
# This file lists tools managed by Rokit, a toolchain manager for Roblox projects.
# For more information, see https://github.com/rojo-rbx/rokit
# New tools can be added by running `rokit add <tool>` 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"

View file

@ -10,14 +10,14 @@ local function is_action(v: any)
end end
local function action(callback: (Instance) -> (), priority: number?): Action local function action(callback: (Instance) -> (), priority: number?): Action
local t = { local a = {
priority = priority or 1, priority = priority or 1,
callback = callback callback = callback
} }
setmetatable(t :: any, ActionMT) setmetatable(a :: any, ActionMT)
return table.freeze(t) return table.freeze(a)
end end
return function() return function()

View file

@ -1,161 +1,161 @@
if not game then script = require "test/relative-string" end local typeof = game and typeof or require "../test/mock".typeof :: never
local 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(script.Parent.flags) local flags = require "./flags"
local throw = require(script.Parent.throw) local implicit_effect = require "./implicit_effect"
local bind = require(script.Parent.bind) local _, is_action = require "./action"()
local _, is_action = require(script.Parent.action)() local graph = require "./graph"
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
type Array<V> = { V } type Array<V> = { V }
type ArrayOrV<V> = {ArrayOrV<V>} | V
type Map<K, V> = { [K]: V } type Map<K, V> = { [K]: V }
-- buffer of event -> callback to connect after properties are set type Cache = {
local event_buffer = {} :: Map<string, () -> ()> -- event listeners to connect after properties are set
events: Array<
| string -- 1. event name
| () -> () -- 2. listener
>,
-- buffer of priority -> callback to run after events are connected -- actions to run after events are connected
local action_buffers = {} :: Map<number, Array<(Instance) -> ()>> actions: Map<
number, -- priority
Array<(Instance) -> ()> -- action callbacks
>,
-- lazily create buffers on nil index -- what to parent the instance to after running actions
setmetatable(action_buffers :: any, { parent: unknown,
__index = function(_, i: number)
action_buffers[i] = {} -- cache to detect duplicate property setting at same nesting depth
return action_buffers[i] nested_debug: Map<
number, -- depth
Map<string, true> -- set of property names
>,
-- each nested layer occupies two indexes: 1. table ref 2. nested depth
-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
nested_stack: { {} | number }
}
local free_cache: Cache?
local function borrow_cache(): Cache
if free_cache then
local cache = free_cache
free_cache = nil
return cache
else
return {
events = {},
actions = setmetatable({} :: any, { -- lazy init
__index = function(self, i) self[i] = {}; return self[i] end
}),
parent = nil,
nested_debug = setmetatable({} :: any, {
__index = function(self, i: number) self[i] = {}; return self[i] end
}),
nested_stack = {}
}
end end
})
-- cache in strict mode to detect duplicate property set at same nesting level
local nested_debug_cache = {} :: Map<number, Map<string, true>>
setmetatable(nested_debug_cache :: any, {
__index = function(_, i: number)
nested_debug_cache[i] = {}
return nested_debug_cache[i]
end
})
-- use stack instead of recursive function to process nested layers one at time
-- deeper-nested properties take precedence over shallower-nested ones
-- each nested layer occupies two indexes: 1. table ref 2. nested depth
-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
local nested_stack = {} :: { {} | number }
-- todo: solution without manual updating of this table
-- map of datatype names to class default constructor for aggregate init
local aggregates = {}
for i, v in next, {
CFrame = CFrame,
Color3 = Color3,
UDim = UDim,
UDim2 = UDim2,
Vector2 = Vector2,
Vector3 = Vector3,
Rect = Rect
} do
aggregates[i] = v.new
end end
-- processes a potentially nested table of values to assign to an instance local function return_cache(cache: Cache )
local function process_props(instance: Instance, properties: Map<unknown, unknown>) free_cache = cache
local strict = flags.strict end
table.clear(nested_stack) local function process_properties(properties: Map<unknown, unknown>, instance: Instance, cache: Cache, depth: number)
if strict then table.clear(nested_debug_cache) end
local depth = 1
repeat
for property, value in properties do for property, value in properties do
if type(property) == "string" then if type(property) == "string" then
if strict then -- check for duplicate prop assignment at nesting layer if flags.strict then -- check for duplicate property assignment at nesting depth
if nested_debug_cache[depth][property] then if cache.nested_debug[depth][property] then
throw(`duplicate property {property} at depth {depth}`) error(`duplicate property {property} at depth {depth}`, 0)
end end
nested_debug_cache[depth][property] = true cache.nested_debug[depth][property] = true
end end
if type(value) == "table" then -- attempt aggregate init if property == "Parent" then
local ctor = aggregates[typeof((instance :: any)[property])] cache.parent = value
if ctor == nil then continue
throw(`cannot aggregate type {typeof(value)} for property {property}`)
end end
(instance :: any)[property] = ctor(unpack(value :: {}))
elseif type(value) == "function" then if type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> () -- add event to buffer table.insert(cache.events, property) -- add event name to buffer
table.insert(cache.events, value :: () -> ()) -- add event listener to buffer
else else
bind.property(instance, property, value :: () -> ()) -- bind property implicit_effect.property(instance, property, value :: () -> ()) -- create implicit effect for property
end end
else else
(instance :: any)[property] = value -- set property (instance :: any)[property] = value -- set property
end end
elseif type(property) == "number" then elseif type(property) == "number" then
if type(value) == "function" then if type(value) == "function" then
bind.children(instance, value :: () -> Instance | Array<Instance>) -- bind children implicit_effect.children(instance, value :: () -> ArrayOrV<Instance>) -- bind children
elseif type(value) == "table" then elseif type(value) == "table" then
if is_action(value) then if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer 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 else
table.insert(nested_stack, value :: {}) process_properties(value :: Map<unknown, unknown>, instance, cache, depth + 1)
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
end end
else elseif type(value) == "userdata" then
(value :: Instance).Parent = instance -- parent child (value :: Instance).Parent = instance -- parent child
end end
end end
end end
-- pop next nested table off stack
depth = table.remove(nested_stack) :: number
properties = table.remove(nested_stack) :: {}
until not properties
end end
-- applies table of nested properties to an instance using full vide semantics -- applies table of nested properties to an instance using full vide semantics
local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T
if not properties then if not properties then
throw("no properties given, did you forget to call the constructor returned by create()?") error "attempt to call a constructor returned by create() with no properties"
end end
-- queue parent assignment if any for last local caches = borrow_cache()
local parent: unknown = properties.Parent local events = caches.events
if parent then properties.Parent = nil end local actions = caches.actions
local nested_debug = caches.nested_debug
local nested_stack = caches.nested_stack
-- reset buffers -- process all properties
table.clear(event_buffer) local depth = 1
for _, buffer in next, action_buffers do repeat
table.clear(buffer) process_properties(properties, instance, caches, depth)
depth = table.remove(nested_stack) :: number
properties = table.remove(nested_stack) :: {}
until not properties
for i = 1, #events, 2 do
local event_name = events[i]
local event_listener = events[i + 1]
;(instance :: any)[event_name]:Connect(event_listener)
end end
-- process all properties for immediate setting or buffering for _, queued in actions do
process_props(instance, properties) for _, callback in queued do
-- connect buffered events
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
-- run buffered actions
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback(instance) callback(instance)
end end
end end
-- finally set parent if any local parent = caches.parent
if parent then if parent then
if type(parent) == "function" then if type(parent) == "function" then
bind.parent(instance, parent :: () -> Instance) implicit_effect.parent(instance, parent :: () -> Instance)
else else
instance.Parent = parent :: Instance instance.Parent = parent :: Instance
end end
end end
table.clear(events)
for _, queued in actions do table.clear(queued) end
caches.parent = nil
if flags.strict then table.clear(nested_debug) end
table.clear(nested_stack)
return_cache(caches)
return instance return instance
end end

23
src/batch.luau Normal file
View file

@ -0,0 +1,23 @@
local flags = require "./flags"
local graph = require "./graph"
local function batch(setter: () -> ())
local already_batching = flags.batch
local from
if not already_batching then
flags.batch = true
from = graph.get_update_queue_length()
end
local ok, err: string? = xpcall(setter, debug.traceback)
if not already_batching then
flags.batch = false
graph.flush_update_queue(from)
end
if not ok then error(`error occured while batching updates: {err}`, 0) end
end
return batch

View file

@ -1,126 +0,0 @@
if not game then script = require "test/relative-string" end
local trace = require(script.Parent.trace)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create_node = graph.create_node
local get_owning_scope = graph.get_owning_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
function create_binding<T>(updater: (T) -> T, binding: T)
if flags.strict then
-- track bind creation trace
local fn = updater
local bind_trace = debug.traceback(nil, trace()-1)
updater = function(...)
local ok, result = xpcall(fn, function(err: string)
return err
end, ...)
if not ok then
local btype =
if (binding :: any).property then (binding :: any).property
elseif (binding :: any).parent then "Parent"
else "children"
error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0)
end
return result
end
end
local owner = get_owning_scope()
local node = create_node(binding, updater)
set_owner(node, owner)
evaluate_node(node)
end
type PropertyBinding = {
instance: Instance,
property: string,
source: () -> unknown
}
local function update_property(p: PropertyBinding)
(p.instance :: any)[p.property] = p.source()
return p
end
type ParentBinding = {
instance: Instance,
parent: () -> Instance
}
local function update_parent(p: ParentBinding)
p.instance.Parent = p.parent()
return p
end
type ChildrenBinding = {
instance: Instance,
cur_children_set: { [Instance]: true },
new_children_set: { [Instance]: true },
children: () -> Instance | { Instance }
}
local function update_children(p: ChildrenBinding)
local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update
local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update
local new_children = p.children() -- all (and only) children that should be parented after this update
if type(new_children) ~= "table" then
new_children = { new_children }
end
if new_children then
for _, child in next, new_children :: { Instance } do
new_child_set[child] = true -- record child set from this update
if not cur_children_set[child] then
child.Parent = p.instance -- if child wasn't already parented then parent it
else
cur_children_set[child] = nil -- remove child from cache if it was already in cache
end
end
end
for child in next, cur_children_set do
child.Parent = nil -- unparent all children that weren't in the new children set
end
table.clear(cur_children_set) -- clear cache, preserve capacity
p.cur_children_set, p.new_children_set = new_child_set, cur_children_set
return p
end
return {
property = function(instance, property, source)
return create_binding(update_property, {
instance = instance,
property = property,
source = source
})
end,
parent = function(instance, parent)
return create_binding(update_parent, {
instance = instance,
parent = parent
})
end,
children = function(instance, children)
return create_binding(update_children, {
instance = instance,
cur_children_set = {},
new_children_set = {},
children = children
})
end
}

40
src/branch.luau Normal file
View file

@ -0,0 +1,40 @@
local graph = require "./graph"
type Node<T> = graph.Node<T>
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<T>(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

View file

@ -1,7 +1,5 @@
if not game then script = require "test/relative-string" end local action = require "./action"()
local cleanup = require "./cleanup"
local action = require(script.Parent.action)()
local cleanup = require(script.Parent.cleanup)
local function changed<T>(property: string, callback: (T) -> ()) local function changed<T>(property: string, callback: (T) -> ())
return action(function(instance) return action(function(instance)
@ -12,6 +10,8 @@ local function changed<T>(property: string, callback: (T) -> ())
cleanup(function() cleanup(function()
con:Disconnect() con:Disconnect()
end) end)
callback((instance :: any)[property])
end) end)
end end

View file

@ -1,19 +1,43 @@
if not game then script = require "test/relative-string" end local typeof = game and typeof or require "../test/mock".typeof :: never
local throw = require(script.Parent.throw) local graph = require "./graph"
local graph = require(script.Parent.graph)
local get_scope = graph.get_scope local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup local push_cleanup = graph.push_cleanup
local function cleanup(callback: () -> ()) local function helper(obj: any)
return
if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end
elseif type(obj) == "thread" then function() task.cancel(obj) end
elseif typeof(obj) == "Instance" then function() obj:Destroy() end
elseif obj.destroy then function() obj:destroy() end
elseif obj.disconnect then function() obj:disconnect() end
elseif obj.Destroy then function() obj:Destroy() end
elseif obj.Disconnect then function() obj:Disconnect() end
else error "cannot cleanup given object"
end
local function cleanup(value: unknown)
local scope = get_scope() local scope = get_scope()
if not scope then if not scope then
throw "cannot cleanup in a non-reactive scope" error "cannot cleanup outside a stable or reactive scope"
end; assert(scope) end; assert(scope)
add_cleanup(scope, callback) if type(value) == "function" then
push_cleanup(scope, value :: () -> ())
else
push_cleanup(scope, helper(value))
end
end end
return cleanup type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () }
type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () }
return cleanup ::
( (callback: () -> ()) -> () ) &
( (thread: thread) -> () ) &
( (instance: Destroyable) -> () ) &
( (connection: Disconnectable) -> () ) &
( (instance: Instance) -> () ) &
( (connection: RBXScriptConnection) -> () )

74
src/context.luau Normal file
View file

@ -0,0 +1,74 @@
local graph = require "./graph"
type Node<T> = graph.Node<T>
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>(T, () -> U) -> U)
local nil_symbol = newproxy()
local count = 0
local function context<T>(...: T): Context<T>
count += 1
local id = count
local has_default = select("#", ...) > 0
local default_value = ...
return function<T>(...): any -- todo: fix type error
local scope: Node<unknown>? | 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

View file

@ -1,74 +1,119 @@
if not game then script = require "test/relative-string" end local typeof = game and typeof or require "../test/mock".typeof :: never
local typeof = game and typeof or require "test/mock".typeof:: never local Instance = game and Instance or require "../test/mock".Instance :: never
local Instance = game and Instance or require "test/mock".Instance :: never
local throw = require(script.Parent.throw) local defaults = require "./defaults"
local defaults = require(script.Parent.defaults) local apply = require "./apply"
local apply = require(script.Parent.apply) local flags = require "./flags"
local memoize = require(script.Parent.memoize)
local function create_instance(class: string) local function create_constructor_for_class(class: string): ({ [unknown]: unknown }) -> Instance
local function constructor(properties: { [unknown]: unknown }): Instance
local ok, instance: Instance = pcall(Instance.new, class :: any) local ok, instance: Instance = pcall(Instance.new, class :: any)
if not ok then throw(`invalid class name, could not create instance of class { class }`) end if not ok then error(`invalid class name {class}`, 0) end
if flags.defaults then
local default: { [string]: unknown }? = defaults[class] local default: { [string]: unknown }? = defaults[class]
if default then if default then
for i, v in next, default do for i, v in default do
(instance :: any)[i] = v (instance :: any)[i] = v
end end
end end
return function(properties: { [any]: unknown }): Instance
return apply(instance:Clone(), properties)
end end
end; create_instance = memoize(create_instance) -- always return same constructor for given class
local function clone_instance(instance: Instance) return apply(instance, properties)
return function(properties: { [any]: unknown }): Instance
local clone = instance:Clone()
if not clone then error("Attempt to clone a non-archivable instance", 3) end
return apply(clone, properties)
end end
return constructor
end end
local function create(class_or_instance: string|Instance) local constructor_cache = {} :: { [string]: ({ [unknown]: unknown }) -> Instance }
-- todo: remove support for different overloads
local function create(class_or_instance: string|Instance, properties: { [unknown]: unknown }?): unknown
if type(class_or_instance) ~= "string" and typeof(class_or_instance) ~= "Instance" then
error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0)
end
local constructor: ({ [unknown]: unknown }) -> Instance
if type(class_or_instance) == "string" then if type(class_or_instance) == "string" then
return create_instance(class_or_instance) constructor = constructor_cache[class_or_instance]
elseif typeof(class_or_instance) == "Instance" then if not constructor then
return clone_instance(class_or_instance) constructor = create_constructor_for_class(class_or_instance)
else constructor_cache[class_or_instance] = constructor
throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance))
end end
return nil :: never else
constructor = function(props)
local clone = assert(class_or_instance:Clone(), "attempt to clone a non-archivable instance")
return apply(clone, props)
end
end
return if properties
then constructor(properties)
else constructor
end end
type Props = { [any]: any } export type Instances = {
return (create :: any) :: Folder: Folder,
( <T>(T & Instance) -> (Props) -> T ) & BillboardGui: BillboardGui,
( ("Folder") -> (Props) -> Folder ) & CanvasGroup: CanvasGroup,
( ("BillboardGui") -> (Props) -> BillboardGui ) & Frame: Frame,
( ("CanvasGroup") -> (Props) -> CanvasGroup ) & ImageButton: ImageButton,
( ("Frame") -> (Props) -> Frame ) & ImageLabel: ImageLabel,
( ("ImageButton") -> (Props) -> ImageButton ) & ScreenGui: ScreenGui,
( ("ImageLabel") -> (Props) -> ImageLabel ) & ScrollingFrame: ScrollingFrame,
( ("ScreenGui") -> (Props) -> ScreenGui ) & SurfaceGui: SurfaceGui,
( ("ScrollingFrame") -> (Props) -> ScrollingFrame ) & TextBox: TextBox,
( ("SurfaceGui") -> (Props) -> SurfaceGui ) & TextButton: TextButton,
( ("TextBox") -> (Props) -> TextBox ) & TextLabel: TextLabel,
( ("TextButton") -> (Props) -> TextButton ) & UIAspectRatioConstraint: UIAspectRatioConstraint,
( ("TextLabel") -> (Props) -> TextLabel ) & UICorner: UICorner,
( ("UIAspectRatioConstraint") -> (Props) -> UIAspectRatioConstraint ) & UIGradient: UIGradient,
( ("UICorner") -> (Props) -> UICorner ) & UIGridLayout: UIGridLayout,
( ("UIGradient") -> (Props) -> UIGradient ) & UIListLayout: UIListLayout,
( ("UIGridLayout") -> (Props) -> UIGridLayout ) & UISizeConstraint: UISizeConstraint,
( ("UIListLayout") -> (Props) -> UIListLayout ) & UITextSizeConstraint: UITextSizeConstraint,
( ("UIPadding") -> (Props) -> UIPadding ) & UIScale: UIScale,
( ("UIPageLayout") -> (Props) -> UIPageLayout ) & UIPadding: UIPadding,
( ("UIScale") -> (Props) -> UIScale ) & UIStroke: UIStroke,
( ("UISizeConstraint") -> (Props) -> UISizeConstraint ) & UIFlexItem: UIFlexItem,
( ("UIStroke") -> (Props) -> UIStroke ) & UIPageLayout: UIPageLayout,
( ("UITableLayout") -> (Props) -> UITableLayout ) & UITableLayout: UITableLayout,
( ("UITextSizeConstraint") -> (Props) -> UITextSizeConstraint ) & VideoFrame: VideoFrame,
( ("VideoFrame") -> (Props) -> VideoFrame ) & ViewportFrame: ViewportFrame,
( ("ViewportFrame") -> (Props) -> ViewportFrame ) & ProximityPrompt: ProximityPrompt,
( (string) -> (Props) -> Instance ) 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>(Name|keyof<Instances>|"") -> (Properties<index<Instances, Name>>) -> index<Instances, Name>
return create :: Create

View file

@ -1,11 +1,10 @@
local Enum = game and Enum or require "test/mock".Enum :: never local Enum = game and Enum or require "../test/mock".Enum :: never
local Color3 = game and Color3 or require "test/mock".Color3 :: never local Color3 = game and Color3 or require "../test/mock".Color3 :: never
local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never
return { return {
Part = { Part = {
Material = Enum.Material.SmoothPlastic, Material = Enum.Material.SmoothPlastic,
Size = Vector3.new(1, 1, 1), Size = vector.create(1, 1, 1),
Anchored = true Anchored = true
}, },
@ -111,4 +110,4 @@ return {
BorderColor3 = Color3.new(0, 0, 0), BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0 BorderSizePixel = 0
} }
} } :: { [string]: { [string]: unknown} }

View file

@ -1,22 +1,16 @@
if not game then script = require "test/relative-string" end local graph = require "./graph"
local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local set_owner = graph.set_owner local push_scope_as_child_of = graph.push_scope_as_child_of
local track = graph.track local assert_stable_scope = graph.assert_stable_scope
local get_owning_scope = graph.get_owning_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local function derive<T>(source: () -> T): () -> T local function derive<T>(source: () -> T): () -> T
local owner = get_owning_scope() local node = create_node(assert_stable_scope(), source, false :: any)
local node = create_node(false :: any, source)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
return function() return function()
track(node) push_scope_as_child_of(node)
return node.cache return node.cache
end end
end end

View file

@ -1,17 +1,11 @@
if not game then script = require "test/relative-string" end local graph = require "./graph"
local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local get_owning_scope = graph.get_owning_scope local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local function effect<T>(callback: (T) -> T, initial_value: T) local function effect<T>(callback: (T) -> T, initial_value: T)
local owner = get_owning_scope() local node = create_node(assert_stable_scope(), callback, initial_value)
local node = create_node(initial_value, callback)
set_owner(node, owner)
evaluate_node(node) evaluate_node(node)
end end

View file

@ -4,4 +4,9 @@ end
local is_O2 = inline_test() ~= "inline_test" local is_O2 = inline_test() ~= "inline_test"
return { strict = not is_O2 } return {
strict = not is_O2,
defaults = true,
defer_nested_properties = true,
batch = false,
}

View file

@ -1,9 +1,6 @@
if not game then script = require "test/relative-string" end local flags = require "./flags"
local throw = require(script.Parent.throw) export type SourceNode<T> = {
local flags = require(script.Parent.flags)
export type StartNode<T> = {
cache: T, cache: T,
[number]: Node<T> [number]: Node<T>
} }
@ -12,73 +9,81 @@ export type Node<T> = {
cache: T, cache: T,
effect: ((T) -> T) | false, effect: ((T) -> T) | false,
cleanups: { () -> () } | false, cleanups: { () -> () } | false,
parents: { owner: StartNode<T>?, [number]: StartNode<T> },
[number]: Node<T> context: { [number]: unknown } | false,
owned: { Node<T> } | false,
owner: Node<T> | false,
parents: { SourceNode<T> },
[number]: Node<T> -- children
} }
-- reactive scope stack local scopes = { n = 0 } :: { [number]: Node<any>, n: number } -- scopes stack
local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
-- runs a given callback in a context that Luau does not allow yielding in local function efn(err: string)
local check_for_yield: <T...>(fn: (T...) -> (), T...) -> (boolean, string?) do local trace = debug.traceback(err, 2)
local t = { __mode = "kv" }
setmetatable(t, t)
check_for_yield = function(fn, ...: any) if string.find(err, "^effect error stacktrace") then -- if effect error is nested
local args = { ... } trace = string.gsub(" " .. trace, "\n", function() -- indent entire error
return "\n "
t.__unm = function(_)
fn(unpack(args))
end
local ok, err: string? = pcall(function()
local _ = -t
end) end)
return ok, if err == "attempt to yield across metamethod/C-call boundary"
or err == "thread is not yieldable" then "yield occured"
else err
end end
trace ..= "\nsource update stacktrace:"
return trace
end
local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U)
local thread = coroutine.create(xpcall)
--local function efn(err: string) return debug.traceback(err, 3) end
local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg)
assert(resume_ok)
if coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "attempt to yield in reactive scope")
end
return run_ok, result
end end
local function get_scope(): Node<unknown>? local function get_scope(): Node<unknown>?
return scopes[scopes.n] return scopes[scopes.n]
end end
local function get_owning_scope(): Node<unknown> local function assert_stable_scope(): Node<unknown>
local scope = get_scope() local scope = get_scope()
if not scope then if not scope then
local caller_name = debug.info(2, "n") local caller_name = debug.info(2, "n")
return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) return error(`cannot use {caller_name}() outside a stable or reactive scope`, 0)
elseif scope.effect then elseif scope.effect then
throw("owning scope is not stable; are you trying to derive a new source from within a side-effect?") error("cannot create a new reactive scope inside another reactive scope", 0)
end end
return scope return scope
end end
local function add_child<T>(parent: StartNode<any>, child: Node<any>) local function push_child<T>(parent: SourceNode<any>, child: Node<any>)
table.insert(parent, child) table.insert(parent, child)
table.insert(child.parents, parent) table.insert(child.parents, parent)
end end
local function set_owner(node: Node<any>, owner: Node<any>) local function push_scope<T>(node: Node<T>)
node.parents.owner = owner
table.insert(owner, node)
end
local function open_scope<T>(node: Node<T>)
local n = scopes.n + 1 local n = scopes.n + 1
scopes.n = n scopes.n = n
scopes[n] = node scopes[n] = node
end end
local function close_scope() local function pop_scope()
local n = scopes.n local n = scopes.n
scopes.n = n - 1 scopes.n = n - 1
scopes[n] = nil scopes[n] = nil
end end
local function add_cleanup<T>(node: Node<T>, cleanup: () -> ()) local function push_cleanup<T>(node: Node<T>, cleanup: () -> ())
if node.cleanups then if node.cleanups then
table.insert(node.cleanups, cleanup) table.insert(node.cleanups, cleanup)
else else
@ -86,147 +91,197 @@ local function add_cleanup<T>(node: Node<T>, cleanup: () -> ())
end end
end end
local function run_cleanups<T>(node: Node<T>) local function flush_cleanups<T>(node: Node<T>)
if node.cleanups then if node.cleanups then
for _, fn in next, node.cleanups do for _, fn in node.cleanups do
local ok, err: string? = pcall(fn) local ok, err: string? = xpcall(fn, debug.traceback)
if not ok then throw(`cleanup error: {err}`) end if not ok then error(`cleanup error: {err}`, 0) end
end end
table.clear(node.cleanups) table.clear(node.cleanups)
end end
end end
local function remove_child<T>(parent: StartNode<T>, child: Node<T>) local function find_and_swap_pop<T>(t: { T }, v: T)
local idx = table.find(parent, child) local i = table.find(t, v) :: number
assert(idx, "child not found") local n = #t
local n = #parent t[i] = t[n]
parent[idx] = parent[n] t[n] = nil
parent[n] = nil
end end
local function unparent<T>(node: Node<T>) local function unparent<T>(node: Node<T>)
local parents = node.parents local parents = node.parents
for i, parent in ipairs(parents) do for i, parent in parents do
remove_child(parent, node) find_and_swap_pop(parent, node)
parents[i] = nil parents[i] = nil
end end
end end
local function destroy<T>(node: Node<T>) local function destroy<T>(node: Node<T>)
run_cleanups(node) if flags.strict and table.find(scopes, node) then
unparent(node) error("attempt to destroy an active scope", 0)
node.effect = false
if node.parents.owner then
remove_child(node.parents.owner, node)
node.parents.owner = nil
end end
while node[1] do destroy(node[1]) end flush_cleanups(node)
unparent(node)
if node.owner then
find_and_swap_pop(node.owner.owned :: { Node<T> }, node)
node.owner = false
end
if node.owned then
local owned = node.owned
while owned[1] do destroy(owned[1]) end
end
end
local function destroy_owned<T>(node: Node<T>)
if node.owned then
local owned = node.owned
while owned[1] do destroy(owned[1]) end
end
end end
local update_queue = { n = 0 } :: { n: number, [number]: Node<any> } local update_queue = { n = 0 } :: { n: number, [number]: Node<any> }
local function evaluate_node<T>(node: Node<T>) local function evaluate_node<T>(node: Node<T>)
local cur_value = node.cache
if flags.strict then if flags.strict then
run_cleanups(node) if table.find(scopes, node) then
open_scope(node) error("a scope, that should rerun due to the update of a source, is already active", 0)
local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value)
close_scope()
if not ok then throw(err :: string) end
end end
run_cleanups(node) -- todo: move in scope? local initial_value = node.cache
open_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) for i = 1, 2 do
local cur_value = node.cache
close_scope() 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 if not ok then
table.clear(update_queue) table.clear(update_queue)
update_queue.n = 0 update_queue.n = 0
throw(`side-effect error from source update\n{new_value}`) error(`effect error stacktrace\n{new_value :: string}`, 0)
end
node.cache = new_value :: T
end
return initial_value ~= node.cache
else
local cur_value = node.cache
flush_cleanups(node)
destroy_owned(node)
push_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, node.cache)
pop_scope()
if not ok then
table.clear(update_queue)
update_queue.n = 0
error(`effect error:\n{new_value}\n`, 0)
end end
node.cache = new_value node.cache = new_value
return cur_value ~= new_value
return cur_value ~= new_value -- node has changed value end
end end
local function update_from<T>(node: StartNode<T>, n0: number) local function queue_children_for_update<T>(node: SourceNode<T>)
if not node[1] then return end local i = update_queue.n
while node[1] do
local n = n0
-- unparent all children and queue for eval
do
local i = 1
local child = node[i]
while child do
unparent(child)
n += 1
update_queue[n] = child
local next_child = node[i]
-- children who have this parent as an owner will not be unparented
-- if such a child is encountered then skip it
if next_child == child then
i += 1 i += 1
next_child = node[i] update_queue[i] = node[1]
unparent(node[1])
end
update_queue.n = i
end
local function get_update_queue_length()
return update_queue.n
end
local function flush_update_queue(from: number)
local i = from + 1
while i <= update_queue.n do
local node = update_queue[i]
--assert(node.effect)
if node.owner and evaluate_node(node) then
queue_children_for_update(node)
end end
child = next_child update_queue[i] = false :: any
end i += 1
end end
update_queue.n = n update_queue.n = from
end
-- evaluate all queued children local function update_descendants<T>(root: SourceNode<T>)
for i = n0 + 1, n do local n0 = update_queue.n
local child = update_queue[i] queue_children_for_update(root)
if not child.effect then continue end
if evaluate_node(child) then if flags.batch then return end
update_from(child, n)
local i = n0 + 1
while i <= update_queue.n do
local node = update_queue[i]
--assert(node.effect)
-- check if node is still owned in case destroyed after queued
if node.owner and evaluate_node(node) then
queue_children_for_update(node)
end end
update_queue[i] = false :: any -- false instead of nil to avoid sparse update_queue[i] = false :: any -- false instead of nil to avoid sparse
i += 1
end end
update_queue.n = n0 update_queue.n = n0
end end
local function update<T>(node: StartNode<T>) local function push_scope_as_child_of<T>(node: SourceNode<T>)
update_from(node, update_queue.n)
end
local function track<T>(node: StartNode<T>)
local scope = get_scope() local scope = get_scope()
if scope and scope.effect then -- do not track nodes with no effect if scope and scope.effect then -- do not track nodes with no effect
add_child(node, scope) push_child(node, scope)
end end
end end
local function create_node<T>(value: T, effect: false | (T) -> T): Node<T> local function create_node<T>(owner: false | Node<any>, effect: false | (T) -> T, value: T): Node<T>
return { local node: Node<T> = {
cache = value, cache = value,
effect = effect, effect = effect,
cleanups = false, cleanups = false,
context = false,
owner = owner,
owned = false,
parents = {}, parents = {},
} }
if owner then
if owner.owned then
table.insert(owner.owned, node)
else
owner.owned = { node }
end
end
return node
end end
local function create_start_node<T>(value: T): StartNode<T> local function create_source_node<T>(value: T): SourceNode<T>
return { cache = value } return { cache = value }
end end
@ -234,21 +289,33 @@ local function get_children<T>(node: Node<T>): { Node<unknown> }
return { unpack(node) } :: { Node<any> } return { unpack(node) } :: { Node<any> }
end end
local function set_context<T>(node: Node<T>, key: number, value: unknown)
if node.context then
node.context[key] = value
else
node.context = { [key] = value }
end
end
return table.freeze { return table.freeze {
open_scope = open_scope, push_scope = push_scope,
close_scope = close_scope, pop_scope = pop_scope,
evaluate_node = evaluate_node, evaluate_node = evaluate_node,
get_scope = get_scope, get_scope = get_scope,
get_owning_scope = get_owning_scope, assert_stable_scope = assert_stable_scope,
add_cleanup = add_cleanup, push_cleanup = push_cleanup,
set_owner = set_owner,
destroy = destroy, destroy = destroy,
run_cleanups = run_cleanups, flush_cleanups = flush_cleanups,
track = track, push_scope_as_child_of = push_scope_as_child_of,
update = update, update_descendants = update_descendants,
add_child = add_child, push_child = push_child,
create_node = create_node, create_node = create_node,
create_start_node = create_start_node, create_source_node = create_source_node,
get_children = get_children, get_children = get_children,
scopes = scopes flush_update_queue = flush_update_queue,
get_update_queue_length = get_update_queue_length,
set_context = set_context,
scopes = scopes,
q = update_queue
} }

125
src/implicit_effect.luau Normal file
View file

@ -0,0 +1,125 @@
local graph = require "./graph"
type Node<T> = graph.Node<T>
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
}

121
src/indexes.luau Normal file
View file

@ -0,0 +1,121 @@
local flags = require "./flags"
local branch = require "./branch"
local source = require "./source"
local effect = require "./effect"
local timeout = require "./timeout" ()
type Array<T> = { T }
type Map<K, V> = { [K]: V }
type Source<T> = () -> T
local function indexes<K, V, Obj>(
input: Source<Map<K, V>>,
component: (Source<V>, K, Source<boolean>) -> (Obj, ...number)
): Source<Array<Obj>>
local update_count = 0
local scopes = {} :: Map<K, {
destroy: () -> (),
object: Obj,
value: V?,
value_source: (V?) -> V,
count: number,
delay: number,
present: (boolean?) -> boolean,
timeout: { cancel: boolean }?,
}>
local output = source({} :: Array<Obj>)
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

View file

@ -1,112 +1,12 @@
-------------------------------------------------------------------------------- assert(game, "when using vide outside of Roblox, require lib.luau instead")
-- vide.luau
-- v0.1.0
--------------------------------------------------------------------------------
if not game then script = require "test/relative-string" end local vide = require("@self/lib")
local root = require(script.root) export type source<T> = vide.source<T>
local mount = require(script.mount) export type Source<T> = vide.Source<T>
local create = require(script.create) export type context<T> = vide.context<T>
local apply = require(script.apply) export type Context<T> = vide.Context<T>
local source = require(script.source) export type Instances = vide.Instances
local effect = require(script.effect) export type Properties<T> = vide.Properties<T>
local cleanup = require(script.cleanup)
local untrack = require(script.untrack)
local derive = require(script.derive)
local switch = require(script.switch)
local show = require(script.show)
local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)()
local action = require(script.action)()
local changed = require(script.changed)
local throw = require(script.throw)
local flags = require(script.flags)
export type Source<T> = source.Source<T>
local function step(dt: number)
if game then
debug.profilebegin("VIDE STEP")
debug.profilebegin("VIDE SPRING")
end
update_springs(dt)
if game then
debug.profileend()
debug.profileend()
end
end
local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number)
task.defer(step, dt)
end)
local vide = {
-- core
root = root,
mount = mount,
create = create,
source = source,
effect = effect,
derive = derive,
switch = switch,
show = show,
indexes = indexes,
values = values,
-- util
cleanup = cleanup,
untrack = untrack,
read = function<T>(value: T | () -> T): T
return if type(value) == "function" then value() else value
end,
-- animations
spring = spring,
-- actions
action = action,
changed = changed,
-- flags
strict = (nil :: any) :: boolean,
-- temporary
apply = function(instance: Instance)
return function(props: { [any]: any })
apply(instance, props)
return instance
end
end,
-- runtime
step = function(dt: number)
if stepped then
stepped:Disconnect()
stepped = nil
end
step(dt)
end
}
setmetatable(vide :: any, {
__index = function(_, index: unknown): ()
if index == "strict" then
return flags.strict
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end,
__newindex = function(_, index: unknown, value: unknown)
if index == "strict" then
flags.strict = value :: boolean
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end
})
return vide return vide

122
src/lib.luau Normal file
View file

@ -0,0 +1,122 @@
local version = { major = 0, minor = 4, patch = 1 }
local root = require "./root"
local branch = require "./branch"
local mount = require "./mount"
local create = require "./create"
local apply = require "./apply"
local source = require "./source"
local effect = require "./effect"
local derive = require "./derive"
local cleanup = require "./cleanup"
local untrack = require "./untrack"
local read = require "./read"
local batch = require "./batch"
local context = require "./context"
local switch = require "./switch"
local show = require "./show"
local indexes = require "./indexes"
local values = require "./values"
local spring, update_springs = require "./spring"()
local action = require "./action"()
local changed = require "./changed"
local timeout, update_timeouts = require "./timeout"()
local flags = require "./flags"
export type Source<T> = source.Source<T>
export type source<T> = Source<T>
export type Context<T> = context.Context<T>
export type context<T> = Context<T>
export type Instances = create.Instances
export type Properties<T> = create.Properties<T>
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

View file

@ -1,226 +0,0 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local create_start_node = graph.create_start_node
local set_owner = graph.set_owner
local track = graph.track
local update = graph.update
local get_owning_scope = graph.get_owning_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local evaluate_node = graph.evaluate_node
local destroy = graph.destroy
type Map<K, V> = { [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<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = get_owning_scope()
local subowner = create_node(false, false)
set_owner(subowner, owner)
local input_cache = {} :: Map<K, VI>
local output_cache = {} :: Map<K, VO>
local input_nodes = {} :: Map<K, StartNode<VI>>
local remove_queue = {} :: { K }
local scopes = {} :: Map<K, Node<unknown>>
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<any>
local node = create_start_node(v)
set_owner(scope, subowner)
open_scope(scope)
local ok, result = pcall(transform, function()
track(node)
return node.cache
end, i)
close_scope()
if not ok then
close_scope() -- subowner scope
error(result, 0)
end
input_nodes[i] = node
output_cache[i] = result
else -- update source
input_nodes[i].cache = v
update(input_nodes[i])
end
input_cache[i] = v
end
end
close_scope()
local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
check_primitives(output_array)
return output_array
end
local node = create_node(false :: any, function()
return update_children(input())
end)
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = get_owning_scope()
local subowner = create_node(false, false)
set_owner(subowner, owner)
local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K>
local output_cache = {} :: Map<VI, VO>
local input_nodes = {} :: Map<VI, StartNode<K>>
local scopes = {} :: Map<VI, Node<unknown>>
local function update_children(data: Map<K, VI>)
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<any>
local node = create_start_node(i)
set_owner(scope, subowner)
open_scope(scope)
local ok, result = pcall(transform, v, function()
track(node)
return node.cache
end)
close_scope()
if not ok then
close_scope() -- subowner scope
error(result, 0)
end
input_nodes[v] = node
output_cache[v] = result
else -- update source
if cv ~= i then
input_nodes[v].cache = i
update(input_nodes[v])
end
cur_input_cache[v] = nil
end
end
close_scope()
-- remove old values
for v in next, cur_input_cache do
destroy(scopes[v])
output_cache[v] = nil
input_nodes[v] = nil
scopes[v] = nil
end
-- update buffer cache
table.clear(cur_input_cache)
cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache
local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
check_primitives(output_array)
return output_array
end
local node = create_node(false :: any, function()
return update_children(input())
end)
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
return function() return indexes, values end

View file

@ -1,17 +0,0 @@
local function memoize<X, Y>(f: (X) -> Y): (X) -> Y
local cache: { [X]: Y? } = {}
return function(x: X): Y
local y = cache[x]
if not y then
y = f(x)
cache[x] = y
end
return y :: Y
end
end
return memoize

View file

@ -1,13 +1,10 @@
if not game then script = require "test/relative-string" end local root = require "./root"
local apply = require "./apply"
local root = require(script.Parent.root)
local apply = require(script.Parent.apply)
local function mount<T>(component: () -> T, target: Instance?): () -> () local function mount<T>(component: () -> T, target: Instance?): () -> ()
return root(function(destroy) return root(function()
local result = component() local result = component()
if target then apply(target, { result }) end if target then apply(target, { result }) end
return destroy
end) end)
end end

5
src/read.luau Normal file
View file

@ -0,0 +1,5 @@
local function read<T>(value: T | () -> T): T
return if type(value) == "function" then value() else value
end
return read

View file

@ -1,38 +1,35 @@
if not game then script = require "test/relative-string" end local graph = require "./graph"
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_node = graph.create_node local create_node = graph.create_node
local open_scope = graph.open_scope local push_scope = graph.push_scope
local close_scope = graph.close_scope local pop_scope = graph.pop_scope
local destroy = graph.destroy local destroy = graph.destroy
local refs = {} local refs = {}
local function root<T...>(fn: (destroy: () -> ()) -> T...): T... local function root<T...>(fn: (destroy: () -> ()) -> T...): (() -> (), T...)
local node = create_node(false, false) local node = create_node(false, false, false)
refs[node] = true -- prevent gc of root node refs[node] = true -- prevent gc of root node
local destroy = function() local destroy = function()
if not refs[node] then throw "root already destroyed" end if not refs[node] then error "root already destroyed" end
refs[node] = nil refs[node] = nil
destroy(node) destroy(node)
end end
open_scope(node) push_scope(node)
local result = { pcall(fn, destroy) } local result = { xpcall(fn, debug.traceback, destroy) }
close_scope() pop_scope()
if not result[1] then if not result[1] then
refs[node] = nil destroy()
throw(`mount error\n{result[2]}`) error(`error while running root():\n\n{result[2]}`, 0)
end end
return unpack(result :: any, 2) return destroy, unpack(result :: any, 2)
end end
return root :: (<T...>(fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ()) return root :: <T...>(fn: (destroy: () -> ()) -> T...) -> (() -> (), T...)

View file

@ -1,18 +1,36 @@
if not game then script = require "test/relative-string" end local source = require "./source"
local derive = require "./derive"
local effect = require "./effect"
local untrack = require "./untrack"
local switch = require "./switch"
local switch = require(script.Parent.switch) type Array<T> = { T }
type Source<T> = () -> T
local function show<T>(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? local function show<T, Obj>(
local function truthy() input: Source<T?>,
return not not source() component: (Source<T>, Source<boolean>) -> (Obj, ...number),
fallback: ((Source<boolean>) -> (Obj, ...number))?
): Source<nil | Obj | Array<Obj>>
local filtered_input = source()
effect(function()
local v = input()
if v then
filtered_input(v)
end end
end)
return switch(truthy) { local input_is_truthy = derive(function()
[true] = component, return not not input()
[false] = fallback, end)
return switch(input_is_truthy) {
[true] = function(present)
return component(filtered_input, present)
end,
[false] = fallback
} }
end end
return show :: return show
(<T>(source: () -> any, component: () -> T) -> () -> T?) &
(<T, U>(source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?)

View file

@ -1,19 +1,17 @@
if not game then script = require "test/relative-string" end local graph = require "./graph"
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_start_node = graph.create_start_node local create_source_node = graph.create_source_node
local track = graph.track local push_scope_as_child_of = graph.push_scope_as_child_of
local update = graph.update local update_descendants = graph.update_descendants
export type Source<T> = (() -> T) & ((T) -> T) export type Source<T> = (() -> T) & ((value: T) -> T)
local function source<T>(initial_value: T): Source<T> local function source<T>(initial_value: T): Source<T>
local node = create_start_node(initial_value) local node = create_source_node(initial_value)
return function(...): T local function update_source(...): T
if select("#", ...) == 0 then -- no args were given if select("#", ...) == 0 then -- no args were given
track(node) push_scope_as_child_of(node)
return node.cache return node.cache
end end
@ -23,9 +21,11 @@ local function source<T>(initial_value: T): Source<T>
end end
node.cache = v node.cache = v
update(node) update_descendants(node)
return v return v
end end
return update_source
end end
return source :: (<T>(initial_value: T) -> Source<T>) & (<T>() -> Source<T>) return source :: (<T>(initial_value: T) -> Source<T>) & (<T>() -> Source<T>)

View file

@ -1,104 +1,84 @@
if not game then script = require "test/relative-string" end local graph = require "./graph"
local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never type Node<T> = graph.Node<T>
type SourceNode<T> = graph.SourceNode<T>
local create_node = graph.create_node
local create_source_node = graph.create_source_node
local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node
local update_descendants = graph.update_descendants
local push_scope_as_child_of = graph.push_scope_as_child_of
local UPDATE_RATE = 120
local TOLERANCE_FACTOR = 10_000
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
--[[ --[[
Supported datatypes:
- number
- CFrame
- Color3
- UDim
- UDim2
- Vector2
- Vector3
- Rect
Unsupported datatypes: Unsupported datatypes:
- bool - bool
- Vector2int16 - Vector2int16
- Vector3int16 - Vector3int16
- EnumItem - EnumItem
]] ]]
local throw = require(script.Parent.throw) type SpringState<T> = {
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local create_start_node = graph.create_start_node
local get_owning_scope = graph.get_owning_scope
local evaluate_node = graph.evaluate_node
local update = graph.update
local set_owner = graph.set_owner
local track = graph.track
local UPDATE_RATE = 120
local TOLERANCE = 0.0001
type Vec3 = Vector3
local function Vec3(x: number?, y: number?, z: number?)
return Vector3.new(x, y, z)
end
local ZERO = Vec3(0, 0, 0)
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData<T> = {
k: number, -- spring constant k: number, -- spring constant
c: number, -- damping coeff c: number, -- damping coeff
-- dimensions 1-3 x0_123: vector, x0_456: vector, -- initial position
x0_123: Vec3, x_123: vector, x_456: vector, -- current position
x1_123: Vec3, x1_123: vector, x1_456: vector, -- target position
v_123: Vec3, v_123: vector, v_456: vector, -- current velocity
-- dimensions 4-6
x0_456: Vec3,
x1_456: Vec3,
v_456: Vec3,
source_value: T -- current value of spring input source source_value: T -- current value of spring input source
} }
type TypeToVec6<T> = (T) -> (Vec3, Vec3) type SpringSettings<T> = ({
type Vec6ToType<T> = (Vec3, Vec3) -> T position: T?,
velocity: T?,
impulse: T?
}) -> ()
type TypeToVec6<T> = (T) -> (vector, vector)
type Vec6ToType<T> = (vector, vector) -> T
local type_to_vec6 = { local type_to_vec6 = {
number = function(v) number = function(v)
return Vec3(v, 0, 0), ZERO return vector.create(v, 0, 0), vector.zero
end :: TypeToVec6<number>, end :: TypeToVec6<number>,
CFrame = function(v) CFrame = function(v)
return v.Position, Vec3(v:ToEulerAnglesXYZ()) return v.Position, vector.create(v:ToEulerAnglesXYZ())
end :: TypeToVec6<CFrame>, end :: TypeToVec6<CFrame>,
Color3 = function(v) Color3 = function(v)
-- todo: hsv, oklab? -- todo: hsv, oklab?
return Vec3(v.R, v.G, v.B), ZERO return vector.create(v.R, v.G, v.B), vector.zero
end :: TypeToVec6<Color3>, end :: TypeToVec6<Color3>,
UDim = function(v) UDim = function(v)
return Vec3(v.Scale, v.Offset, 0), ZERO return vector.create(v.Scale, v.Offset, 0), vector.zero
end :: TypeToVec6<UDim>, end :: TypeToVec6<UDim>,
UDim2 = function(v) UDim2 = function(v)
return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) return vector.create(v.X.Scale, v.X.Offset, v.Y.Scale), vector.create(v.Y.Offset, 0, 0)
end :: TypeToVec6<UDim2>, end :: TypeToVec6<UDim2>,
Vector2 = function(v) Vector2 = function(v)
return Vec3(v.X, v.Y, 0), ZERO return vector.create(v.X, v.Y, 0), vector.zero
end :: TypeToVec6<Vector2>, end :: TypeToVec6<Vector2>,
Vector3 = function(v) Vector3 = function(v)
return v, ZERO return v, vector.zero
end :: TypeToVec6<Vector3>, end :: TypeToVec6<Vector3>,
Rect = function(v) Rect = function(v)
return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) return vector.create(v.Min.X, v.Min.Y, v.Max.X), vector.create(v.Max.Y, 0, 0)
end :: TypeToVec6<Rect> end :: TypeToVec6<Rect>,
table = function(v)
return vector.create(v[1] or 0, v[2] or 0, v[3] or 0), vector.create(v[4] or 0, 0, 0)
end :: TypeToVec6<{ number }>
} }
local vec6_to_type = { local vec6_to_type = {
@ -111,15 +91,15 @@ local vec6_to_type = {
end :: Vec6ToType<CFrame>, end :: Vec6ToType<CFrame>,
Color3 = function(v) Color3 = function(v)
return Color3.new(v.X, v.Y, v.Z) return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1))
end :: Vec6ToType<Color3>, end :: Vec6ToType<Color3>,
UDim = function(v) UDim = function(v)
return UDim.new(v.X, v.Y) return UDim.new(v.X, math.round(v.Y))
end :: Vec6ToType<UDim>, end :: Vec6ToType<UDim>,
UDim2 = function(a, b) UDim2 = function(a, b)
return UDim2.new(a.X, a.Y, a.Z, b.X) return UDim2.new(a.X, math.round(a.Y), a.Z, math.round(b.X))
end :: Vec6ToType<UDim2>, end :: Vec6ToType<UDim2>,
Vector2 = function(v) Vector2 = function(v)
@ -132,12 +112,16 @@ local vec6_to_type = {
Rect = function(a, b) Rect = function(a, b)
return Rect.new(a.X, a.Y, a.Z, b.X) return Rect.new(a.X, a.Y, a.Z, b.X)
end :: Vec6ToType<Rect> end :: Vec6ToType<Rect>,
table = function(a, b)
return { a.X, a.Y, a.Z, b.X }
end :: Vec6ToType<{ number }>
} }
local invalid_type = { local invalid_type = {
__index = function(_, t: string) __index = function(_, t: string)
throw(`cannot spring type {t}`) error(`cannot spring type {t}`, 0)
end end
} }
@ -146,80 +130,133 @@ setmetatable(vec6_to_type, invalid_type)
-- maps spring data to its corresponding output node -- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node -- lifetime of spring data is tied to output node
local springs: { [SpringData<any>]: StartNode<any> } = {} local springs: { [SpringState<unknown>]: SourceNode<unknown> } = {}
setmetatable(springs, { __mode = "v" }) setmetatable(springs :: any, { __mode = "v" })
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): (() -> T, SpringSettings<T>)
local owner = get_owning_scope() local owner = assert_stable_scope()
-- https://en.wikipedia.org/wiki/Damping -- https://en.wikipedia.org/wiki/Damping
local w_n = 2*math.pi / (period or 1) local w_n = 2*math.pi / (period or 1)
local z = damping_ratio or 1 local z = damping_ratio or 1
local k = w_n^2 local k = w_n^2
local c_c = 2*w_n local c_c = 2*w_n
local c = z * c_c local c = z * c_c
-- todo: is there a solution to this other than upping step frequency? -- todo: is there a solution other than reducing step size?
-- todo: this does not catch all solver exploding cases
if c > UPDATE_RATE*2 then -- solver will explode if this is true if c > UPDATE_RATE*2 then -- solver will explode if this is true
throw("spring damping too high, consider reducing damping or increasing period") error("spring damping too high, consider reducing damping or increasing period", 0)
end end
local data: SpringData<T> = { local data: SpringState<T> = {
k = k, k = k,
c = c, c = c,
x0_123 = ZERO, x0_123 = vector.zero,
x1_123 = ZERO, x_123 = vector.zero,
v_123 = ZERO, x1_123 = vector.zero,
v_123 = vector.zero,
x0_456 = ZERO, x0_456 = vector.zero,
x1_456 = ZERO, x_456 = vector.zero,
v_456 = ZERO, x1_456 = vector.zero,
v_456 = vector.zero,
source_value = false :: any, source_value = false :: any,
} }
local output = create_start_node(false :: any) local output = create_source_node(false :: any)
local function updater_effect() local function updater_effect()
local value = source() local value = source()
data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value)
data.source_value = value data.source_value = value
springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs springs[data] = output
return value return value
end end
local updater = create_node(false :: any, updater_effect) local updater = create_node(owner, updater_effect, false :: any)
set_owner(updater, owner)
evaluate_node(updater) evaluate_node(updater)
-- set initial position to goal -- set initial position to goal
data.x0_123, data.x0_456 = data.x1_123, data.x1_456 data.x_123, data.x_456 = data.x1_123, data.x1_456
-- set output to goal -- set output to goal
output.cache = data.source_value output.cache = data.source_value
return function() local config = function(p)
track(output) 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<T>
return function(...)
if select("#", ...) == 0 then -- no args were given
push_scope_as_child_of(output)
return output.cache return output.cache
end end
-- set current position to value
local v = ... :: T
data.x_123, data.x_456 = type_to_vec6[typeof(v)](v)
-- reset velocity
data.v_123 = vector.zero
data.v_456 = vector.zero
-- schedule spring
springs[data] = output
-- set output to value
output.cache = v
return v
end, config
end
-- calculates a float tolerance, based on the magnitude of the float
local function get_min_step(x: number): number
return x/TOLERANCE_FACTOR
end
local function get_min_vector_step(direction: vector): vector
return vector.create(
get_min_step(direction.x),
get_min_step(direction.y),
get_min_step(direction.z)
)
end end
local function step_springs(dt: number) local function step_springs(dt: number)
for data in next, springs do for s in springs do
local k, c, local k = s.k
x0_123, x1_123, u_123, local c = s.c
x0_456, x1_456, u_456 = local x_123, x_456 = s.x_123, s.x_456
data.k, data.c, local x1_123, x1_456 = s.x1_123, s.x1_456
data.x0_123, data.x1_123, data.v_123, local u_123, u_456 = s.v_123, s.v_456
data.x0_456, data.x1_456, data.v_456
-- calculate displacement from target -- calculate displacement from target
local dx_123 = x0_123 - x1_123 local dx_123 = x_123 - x1_123
local dx_456 = x0_456 - x1_456 local dx_456 = x_456 - x1_456
-- calculate spring force -- calculate spring force
local fs_123 = dx_123*-k local fs_123 = dx_123*-k
@ -229,53 +266,50 @@ local function step_springs(dt: number)
local ff_123 = u_123*-c local ff_123 = u_123*-c
local ff_456 = u_456*-c local ff_456 = u_456*-c
-- calculate acceleration step -- calculate acceleration
local dv_123 = (fs_123 + ff_123)*dt local a_123 = (fs_123 + ff_123)
local dv_456 = (fs_456 + ff_456)*dt local a_456 = (fs_456 + ff_456)
-- apply acceleration step -- step acceleration
local v_123 = u_123 + dv_123 local v_123 = u_123 + a_123*dt
local v_456 = u_456 + dv_456 local v_456 = u_456 + a_456*dt
-- calculate new position -- step velocity
local x_123 = x0_123 + v_123*dt local y_123 = x_123 + v_123*dt
local x_456 = x0_456 + v_456*dt local y_456 = x_456 + v_456*dt
data.x0_123, data.x0_456 = x_123, x_456 s.x_123, s.x_456 = y_123, y_456
data.v_123, data.v_456 = v_123, v_456 s.v_123, s.v_456 = v_123, v_456
end end
end end
local remove_queue = {}
local function update_spring_sources() local function update_spring_sources()
for data, output in next, springs do for data, output in springs do
local x0_123, x1_123, v_123, local x0_123, x0_456 = data.x0_123, data.x0_456
x0_456, x1_456, v_456 = local x_123, x_456 = data.x_123, data.x_456
data.x0_123, data.x1_123, data.v_123, local x1_123, x1_456 = data.x1_123, data.x1_456
data.x0_456, data.x1_456, data.v_456 local v_123, v_456 = data.v_123, data.v_456
local dx_123, dx_456 = local tol_123 = vector.abs(get_min_vector_step(x0_123 - x1_123))
x0_123 - x1_123, local tol_456 = vector.abs(get_min_vector_step(x0_456 - x1_456))
x0_456 - x1_456
-- todo: can this false positive? if
if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then -- position is at goal (within tolerance)
-- close enough to target, unshedule spring and set value to target vector.max(vector.abs(x_123 - x1_123), tol_123) == tol_123
table.insert(remove_queue, data) and vector.max(vector.abs(x_456 - x1_456), tol_456) == tol_456
-- velocity is at 0 (within tolerance)
and vector.max(vector.abs(v_123/10), tol_123) == tol_123
and vector.max(vector.abs(v_456/10), tol_456) == tol_456
then
springs[data] = nil
output.cache = data.source_value output.cache = data.source_value
else else
output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) output.cache = vec6_to_type[typeof(data.source_value)](x_123, x_456)
end end
update(output) update_descendants(output)
end end
for _, data in next, remove_queue do
springs[data] = nil
end
table.clear(remove_queue)
end end
return function() return function()

View file

@ -1,67 +1,113 @@
if not game then script = require "test/relative-string" end local branch = require "./branch"
local source = require "./source"
local throw = require(script.Parent.throw) local effect = require "./effect"
local graph = require(script.Parent.graph) local timeout = require "./timeout" ()
type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local track = graph.track
local destroy = graph.destroy
local get_owning_scope = graph.get_owning_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
type Array<T> = { T }
type Map<K, V> = { [K]: V } type Map<K, V> = { [K]: V }
type Source<T> = () -> T
type Component<T> = (Source<boolean>) -> (T, ...number)
local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U? local function switch_map<K, Obj>(
local owner = get_owning_scope() input: Source<K>,
map: Map<K, Component<Obj>>
): Source<nil | Obj | Array<Obj>>
local scopes = {} :: Map<K, {
destroy: () -> (),
object: Obj,
delay: number,
present: (boolean?) -> boolean,
timeout: { cancel: boolean }?
}>
return function(map) local output = source(nil :: nil | Obj | Array<Obj>)
local last_scope: Node<false>? local function update_output()
local last_component: (() -> U)? local objects = {}
local function update(cached): U? for _, scope in scopes do
local component = map[source()] table.insert(objects, scope.object)
if component == last_component then return cached end
last_component = component
if last_scope then
destroy(last_scope :: Node<any>)
last_scope = nil
end end
if component == nil then return nil 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 if type(component) ~= "function" then
throw("map must map a value to a function") error("map must map a value to a function", 0)
end end
local new_scope = create_node(false, false) local present = source(false)
last_scope = new_scope :: Node<any>
set_owner(new_scope, owner) local delay = nil :: number?
open_scope(new_scope) local destroy, object = branch(function()
local object, t = component(present)
delay = t
return object
end)
local ok, result = pcall(component) present(true)
close_scope() scopes[key] = {
destroy = destroy,
if not ok then error(result, 0) end object = object,
delay = delay or 0,
return result present = present,
timeout = nil
}
end
end
end end
local node = create_node(nil :: any, update) update_output()
end)
set_owner(node, owner) return output
evaluate_node(node) end
return function() local function switch<K, Obj>(input: Source<K>): (map: Map<K, Component<Obj>>) -> Source<nil | Obj | Array<Obj>>
track(node) return function(map)
return node.cache return switch_map(input, map)
end
end end
end end

View file

@ -1,9 +0,0 @@
if not game then script = require "test/relative-string" end
local trace = require(script.Parent.trace)
local function throw(msg): any
error(msg, trace()-1)
end
return throw

27
src/timeout.luau Normal file
View file

@ -0,0 +1,27 @@
local queue = {} :: {
{ t: number, fn: () -> (), cancel: boolean }
}
local function timeout(t: number, fn: () -> ())
local handle = { t = t, fn = fn, cancel = false }
table.insert(queue, handle)
return handle
end
local function update_timeouts(dt: number)
for i = #queue, 1, -1 do
local handle = queue[i]
handle.t -= dt
if handle.cancel or handle.t <= 0 then
queue[i] = queue[#queue]
queue[#queue] = nil
if not handle.cancel then
handle.fn()
end
end
end
end
return function() return timeout, update_timeouts end

View file

@ -1,29 +0,0 @@
-- returns path to file as an array with each directory
-- accounts for Roblox and Luau contexts
local function get_path(s)
if string.sub(s, #s - 4, #s) == ".luau" then
s = string.sub(s, 1, #s - 5)
end
return string.split(s, string.match(s, "%w+/") and "/" or ".")
end
-- get directory of vide root
local root do
local path = get_path(debug.info(1, "s"))
root = path[#path - 1]
end
-- finds the first stack depth outside of any vide library function
return function(): number
local stack = 1
local path = get_path(debug.info(stack, "s"))
while path[#path] == root or path[#path - 1] == root do
stack += 1
path = get_path(debug.info(stack, "s"))
end
return stack
end

View file

@ -1,27 +1,25 @@
if not game then script = require "test/relative-string" end local graph = require "./graph"
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local get_scope = graph.get_scope local get_scope = graph.get_scope
local function untrack<T>(source: () -> T): T local function untrack<T>(source: () -> T): T
local scope = get_scope() local scope = get_scope()
if not scope then
throw("cannot untrack in non-reactive scope")
end; assert(scope)
if scope then
-- sources are only tracked if the node in scope has an effect -- sources are only tracked if the node in scope has an effect
local effect = scope.effect local effect = scope.effect
scope.effect = false scope.effect = false
local ok, result = pcall(source) local ok, result = xpcall(source, debug.traceback)
scope.effect = effect :: () -> () scope.effect = effect :: () -> ()
if not ok then error(result, 0) end if not ok then error(result, 0) end
return result return result :: T
else
return source()
end
end end
return untrack return untrack :: ( <T>(fn: () -> T) -> T ) & ( (fn: () -> ()) -> () )

131
src/values.luau Normal file
View file

@ -0,0 +1,131 @@
local flags = require "./flags"
local branch = require "./branch"
local source = require "./source"
local effect = require "./effect"
local timeout = require "./timeout" ()
type Array<T> = { T }
type Map<K, V> = { [K]: V }
type Source<T> = () -> T
local function values<K, V, Obj>(
input: Source<Map<K, V>>,
component: (V, Source<K>, Source<boolean>) -> (Obj, ...number)
): Source<Array<Obj>>
local update_count = 0
local scopes = {} :: Map<V, {
destroy: () -> (),
object: Obj,
index: K?,
index_source: (K?) -> K,
count: number,
delay: number,
present: (boolean?) -> boolean,
timeout: { cancel: boolean }?,
}>
local output = source({} :: Array<Obj>)
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

View file

@ -1,13 +1,17 @@
local testkit = require("test/testkit") local testkit = require("./testkit")
local BENCH, START = testkit.benchmark() local BENCH, START = testkit.benchmark()
local vide = require "src/init" local vide = require "../../vide"
local source = vide.source local source = vide.source
local derive = vide.derive local derive = vide.derive
local effect = vide.effect
local indexes = vide.indexes local indexes = vide.indexes
local values = vide.values local values = vide.values
local batch = vide.batch
local cleanup = vide.cleanup local cleanup = vide.cleanup
local untrack = vide.untrack
local create = vide.create local create = vide.create
local context = vide.context
assert(not vide.strict) assert(not vide.strict)
@ -16,18 +20,18 @@ local function TITLE(name: string)
print(testkit.color.white(name)) print(testkit.color.white(name))
end end
local N = 2^18 -- 262144 local function ROOT_BENCH(name: string, fn: () -> ())
local function WRAP_BENCH(name: string, fn: () -> ())
vide.root(function(destroy) vide.root(function(destroy)
BENCH(name, fn) BENCH(name, fn)
return destroy return destroy
end)() end)()
end end
local N = 2^20
TITLE "sources" TITLE "sources"
WRAP_BENCH("create source", function() BENCH("create source", function()
local cache = table.create(N) local cache = table.create(N)
for i = 1, START(N) do for i = 1, START(N) do
@ -35,7 +39,7 @@ WRAP_BENCH("create source", function()
end end
end) end)
WRAP_BENCH("get value", function() BENCH("get value", function()
local src = source(1) local src = source(1)
for i = 1, START(N) do for i = 1, START(N) do
@ -43,7 +47,7 @@ WRAP_BENCH("get value", function()
end end
end) end)
WRAP_BENCH("set value", function() BENCH("set value", function()
local src = source(1) local src = source(1)
for i = 1, START(N) do for i = 1, START(N) do
@ -51,7 +55,7 @@ WRAP_BENCH("set value", function()
end end
end) end)
WRAP_BENCH("derive 1 source", function() ROOT_BENCH("derive 1 source", function()
local cache = table.create(N) local cache = table.create(N)
local src = source(1) local src = source(1)
@ -62,7 +66,7 @@ WRAP_BENCH("derive 1 source", function()
end end
end) end)
WRAP_BENCH("derive 4 sources", function() ROOT_BENCH("derive 4 sources", function()
local cache = table.create(N) local cache = table.create(N)
local src = vide.source(1) local src = vide.source(1)
local src2 = vide.source(2) local src2 = vide.source(2)
@ -78,7 +82,7 @@ end)
TITLE "graphs" TITLE "graphs"
WRAP_BENCH("update 1->1 graph", function() ROOT_BENCH("update 1->1 graph", function()
local src = source(1) local src = source(1)
local _derived = derive(function() return src() end) local _derived = derive(function() return src() end)
@ -88,7 +92,7 @@ WRAP_BENCH("update 1->1 graph", function()
end end
end) end)
WRAP_BENCH("update 1->1 graph with cleanup", function() ROOT_BENCH("update 1->1 graph with cleanup", function()
local src = source(1) local src = source(1)
derive(function() derive(function()
@ -101,7 +105,7 @@ WRAP_BENCH("update 1->1 graph with cleanup", function()
end end
end) end)
WRAP_BENCH("update 1->1000 graph", function() ROOT_BENCH("update 1->1000 graph", function()
local src = source(-1) local src = source(-1)
for i = 1, 1000 do for i = 1, 1000 do
@ -115,7 +119,7 @@ WRAP_BENCH("update 1->1000 graph", function()
end end
end) end)
WRAP_BENCH("update 1->1->1->1...1000 graph", function() ROOT_BENCH("update 1->1->1->1...1000 graph", function()
local src = source(-1) local src = source(-1)
local last = src local last = src
@ -131,8 +135,28 @@ WRAP_BENCH("update 1->1->1->1...1000 graph", function()
end end
end) end)
-- todo: repeat with batching -- todo: why does it hang at 1k? it didn't before
WRAP_BENCH("update 1000->1 graph", function() ROOT_BENCH("update 500->1 graph", function()
local srcs = {}
for i = 1, 500 do
srcs[i] = source(0)
end
derive(function()
for i = 1, 500 do
srcs[i]()
end
return false
end)
for i = 1, START(1) do
for idx = 1, 500 do
srcs[idx](i)
end
end
end)
ROOT_BENCH("update 1000->1 graph (batched)", function()
local srcs = {} local srcs = {}
for i = 1, 1000 do for i = 1, 1000 do
srcs[i] = source(0) srcs[i] = source(0)
@ -146,14 +170,16 @@ WRAP_BENCH("update 1000->1 graph", function()
end) end)
for i = 1, START(1) do for i = 1, START(1) do
batch(function()
for idx = 1, 1000 do for idx = 1, 1000 do
srcs[idx](i) srcs[idx](i)
end end
end)
end end
end) end)
-- todo: optimize, repeat with batching -- todo: optimize this case
WRAP_BENCH("update 1000x 1->1 common extern. graph", function() ROOT_BENCH("update 1000 1->1 common extern. graph", function()
local ext = source(-1) local ext = source(-1)
local srcs = {} local srcs = {}
@ -173,8 +199,8 @@ end)
TITLE "property apply" TITLE "property apply"
WRAP_BENCH("apply 0 properties", function() ROOT_BENCH("apply 0 properties", function()
local apply = require "src/apply" local apply = require "../src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
for i = 1, START(N) do for i = 1, START(N) do
@ -182,8 +208,8 @@ WRAP_BENCH("apply 0 properties", function()
end end
end) end)
WRAP_BENCH("apply 8 properties", function() ROOT_BENCH("apply 8 properties", function()
local apply = require "src/apply" local apply = require "../src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
for i = 1, START(N) do for i = 1, START(N) do
@ -200,8 +226,8 @@ WRAP_BENCH("apply 8 properties", function()
end end
end) end)
WRAP_BENCH("bind property", function() ROOT_BENCH("bind property", function()
local apply = require "src/apply" local apply = require "../src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
local src = source(1) local src = source(1)
@ -215,8 +241,8 @@ WRAP_BENCH("bind property", function()
return nil return nil
end) end)
WRAP_BENCH("update binding", function() ROOT_BENCH("update binding", function()
local apply = require "src/apply" local apply = require "../src/apply"
local instance = create("Frame") {} local instance = create("Frame") {}
local src = source(1) local src = source(1)
@ -232,11 +258,29 @@ WRAP_BENCH("update binding", function()
return nil return nil
end) end)
TITLE "switch()"
ROOT_BENCH("switch()", function()
local M = 2^8
local map = {}
for i = 1, M do
map[i] = function() return i end
end
local input = source(0)
vide.switch(input)(map)
for i = 1, START(N) do
input(bit32.band(i, M - 1) + 1) -- i % m + 1
end
end)
TITLE "indexes()" TITLE "indexes()"
N /= 1024 N /= 1024
WRAP_BENCH("indexes() all new", function() ROOT_BENCH("indexes() all new", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -254,7 +298,7 @@ WRAP_BENCH("indexes() all new", function()
return nil return nil
end) end)
WRAP_BENCH("indexes() no change", function() ROOT_BENCH("indexes() no change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -274,7 +318,7 @@ WRAP_BENCH("indexes() no change", function()
return nil return nil
end) end)
WRAP_BENCH("indexes() all change", function() ROOT_BENCH("indexes() all change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -298,7 +342,7 @@ WRAP_BENCH("indexes() all change", function()
src(data) src(data)
end) end)
WRAP_BENCH("indexes() all remove", function() ROOT_BENCH("indexes() all remove", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -322,7 +366,7 @@ end)
TITLE "values()" TITLE "values()"
WRAP_BENCH("values() all new", function() ROOT_BENCH("values() all new", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -340,7 +384,7 @@ WRAP_BENCH("values() all new", function()
return nil return nil
end) end)
WRAP_BENCH("values() no change", function() ROOT_BENCH("values() no change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -360,7 +404,7 @@ WRAP_BENCH("values() no change", function()
src(data) src(data)
end) end)
WRAP_BENCH("values() all change", function() ROOT_BENCH("values() all change", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -385,7 +429,7 @@ WRAP_BENCH("values() all change", function()
src(data) src(data)
end) end)
WRAP_BENCH("values() all remove", function() ROOT_BENCH("values() all remove", function()
local data = {} local data = {}
for i = 1, N do for i = 1, N do
@ -405,106 +449,99 @@ WRAP_BENCH("values() all remove", function()
src(data) src(data)
end) end)
N *= 1024 TITLE "context()"
TITLE "cleanup" ROOT_BENCH("set context", function()
local ctx = context()
WRAP_BENCH("register new cleanup", function()
local cleanup = cleanup
local cleaner = function() end
local callers = {}
for i = 1, N do
callers[i] = function(fn, v)
fn(v)
return i -- return unique upvalue to ensure unique closure
end
end
for i = 1, START(N) do for i = 1, START(N) do
callers[i](cleanup, cleaner) ctx(i, function() end)
end end
end) end)
TITLE "aggregate" ROOT_BENCH("get context (depth=1)", function()
local ctx = context()
do
-- the purpose of the two following benchmarks is to measure the overhead of
-- aggregate construction
WRAP_BENCH("set explicit mock vector2", function()
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
local label = create "TextLabel" {
AnchorPoint = Vector2.new(1, 1)
}
local function run()
for i = 1, START(N) do for i = 1, START(N) do
apply(label, { ctx()
AnchorPoint = Vector2.new(i, i)
})
end end
end
ctx(1, function()
run()
end) end)
end)
WRAP_BENCH("set aggregate mock vector2", function() local depth = 10
local apply = require "src/apply" ROOT_BENCH(`get context (depth={depth})`, function()
local Vector2 = require "test/mock".Vector2
local label = create "TextLabel" { local ctx = context()
AnchorPoint = Vector2.new(1, 1)
}
local function run()
for i = 1, START(N) do for i = 1, START(N) do
apply(label, { ctx()
AnchorPoint = { i, i }
})
end end
end
local function nest_effect(fn)
untrack(function()
effect(fn)
return nil
end) end)
end end
-- innacurate due to no Vector3 in vanilla Luau local f = run
-- mock vector is 200x slower than native vector for i = 1, depth - 1 do
local f_inner = f
f = function()
nest_effect(f_inner)
end
end
-- WRAP_BENCH("spring update", function() ctx(1, function()
-- local root, source, spring = vide.root, vide.source, vide.spring f()
end)
end)
-- local src = source(0) TITLE "spring()"
-- root(function() ROOT_BENCH("spring update", function()
-- for i = 1, N do local root, source, spring = vide.root, vide.source, vide.spring
-- spring(src)
-- end
-- START(N) local src = source(0)
-- src(1) root(function()
for i = 1, N do
spring(src)
end
-- return nil START(N)
-- end)
-- end)
-- N /= 1024 src(1)
-- WRAP_BENCH("spring step", function() return nil
-- local root, source, spring = vide.root, vide.source, vide.spring end)
end)
-- local src = source(0) ROOT_BENCH("spring step", function()
local root, source, spring = vide.root, vide.source, vide.spring
-- root(function() local src = source(0)
-- for i = 1, N do
-- spring(src)
-- end
-- src(1) root(function()
for i = 1, N do
spring(src)
end
-- START(N) src(1)
-- vide.step(1/60) START(N)
-- return nil vide.step(1/60)
-- end)
-- end) return nil
end)
end)
return nil return nil

View file

@ -0,0 +1,27 @@
local vide = require "../src/"
local count = vide.source(0)
vide.create("TextButton") {
BackgroundTransparency = 1,
AnchorPoint = "bad value", -- should error
InvalidProperty = true, -- should error
Text = function()
return "count: " .. count()
end,
Size = function() -- should error
return "bad value"
end,
MouseEnter = function(x, y)
end,
Activated = "bad value", -- should error
vide.create "TextLabel" {},
function() end,
}

View file

@ -86,6 +86,9 @@ local Instance = {} :: any do
local proxies = {} :: { [Data]: userdata? } local proxies = {} :: { [Data]: userdata? }
setmetatable(proxies :: any, { __mode = "v" }) 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 get_data(userdata: userdata): Data
local function f(userdata: userdata): ProxyMT local function f(userdata: userdata): ProxyMT
return getmetatable(userdata :: any) return getmetatable(userdata :: any)
@ -94,6 +97,19 @@ local Instance = {} :: any do
return f(userdata).data return f(userdata).data
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
local function is_instance(value: unknown): boolean local function is_instance(value: unknown): boolean
local mt = getmetatable(value :: any) local mt = getmetatable(value :: any)
return mt and mt.data and mt.data.type == "Instance" return mt and mt.data and mt.data.type == "Instance"
@ -101,16 +117,16 @@ local Instance = {} :: any do
local methods = {} local methods = {}
local function __index(userdata: userdata, property: string): () __index = function(userdata: userdata, property: string): ()
local data = get_data(userdata) local data = get_data(userdata)
return if methods[property] then methods[property] return if methods[property] then methods[property]
elseif property == "Name" then data.name elseif property == "Name" then data.name
elseif property == "Parent" then data.parent elseif property == "Parent" then (data.parent and get_proxy(data.parent))
elseif property == "Destroying" then data.destroying elseif property == "Destroying" then data.destroying
else data.properties[property] else data.properties[property]
end end
local function __newindex(userdata: userdata, property: string, value: unknown) __newindex = function(userdata: userdata, property: string, value: unknown)
local data = get_data(userdata) local data = get_data(userdata)
if property == "Name" then if property == "Name" then
if type(value) ~= "string" then error("name must be a string", 2) end if type(value) ~= "string" then error("name must be a string", 2) end
@ -135,19 +151,6 @@ local Instance = {} :: any do
end end
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 function Instance.new(class: string): Instance
local data = { local data = {
name = "UNNAMED", name = "UNNAMED",
@ -257,40 +260,6 @@ local Vector2 = { __type = "Vector2" } :: any do
end end
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 local UDim2 = { __type = "UDim2" } :: any do
function UDim2.new(sx, ox, sy, oy) function UDim2.new(sx, ox, sy, oy)
return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2)) return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2))
@ -330,7 +299,6 @@ return {
Instance = Instance :: typeof(Instance), Instance = Instance :: typeof(Instance),
Color3 = Color3 :: typeof(Color3), Color3 = Color3 :: typeof(Color3),
Vector2 = Vector2 :: typeof(Vector2), Vector2 = Vector2 :: typeof(Vector2),
Vector3 = Vector3 :: typeof(Vector3),
UDim2 = UDim2 :: typeof(UDim2), UDim2 = UDim2 :: typeof(UDim2),
Enum = Enum :: typeof(Enum), Enum = Enum :: typeof(Enum),
typeof = typeof :: typeof(typeof) typeof = typeof :: typeof(typeof)

View file

@ -1,9 +0,0 @@
local function dir(directory: string)
return setmetatable({} :: { [string]: any },
{ __index = function(_, path) return directory .. path end })
end
local script = dir "src/"
script.Parent = dir "src/"
return script

View file

@ -1,25 +1,34 @@
local vide = require "src/init" local vide = require "../../vide"
local testkit = require("test/testkit")
local program_time = os.clock() local function system(): (number) -> number
local MAX = 40
local MIN = 10
local function step(): number local _, input, output = vide.root(function()
local FPS = 60 local input = vide.source(MAX)
local DT = 1/FPS local output = vide.spring(input, 1, .3)
return input, output
end)
repeat until os.clock() - program_time >= DT local T = 10
program_time += DT local t = 0
return DT return function(dt)
t += dt
if t >= T then
t -= T
input(input() == MAX and MIN or MAX)
end
vide.step(dt)
return output()
end
end end
local function main() --------------------------------------------------------------------------------
local TERMINAL_HEIGHT = 73 --* REDUCE IF BAR DOES NOT FIT IN TERMINAL
local MIN_ALPHA = 0.3
local MAX_ALPHA = 0.7
local MIN = TERMINAL_HEIGHT * MIN_ALPHA local function redraw_block(h: number)
local MAX = TERMINAL_HEIGHT * MAX_ALPHA local OFFSET = 70
local OFFSET = TERMINAL_HEIGHT - MAX
local BLOCK = "█" local BLOCK = "█"
@ -35,37 +44,35 @@ local function main()
else "▁" else "▁"
end end
local source = vide.source local h_f = math.floor(h)
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 reset = "\27[H\27[2J" -- ANSI clear terminal
local offset = string.rep("\n", MAX - fv + OFFSET) local offset = string.rep("\n", OFFSET - h_f)
local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv)) local bar = remainder_to_block(h - h_f) .. "\n" .. string.rep(BLOCK .. "\n", h_f)
print(reset .. offset .. bar .. "\n" .. v) --print(reset .. offset .. bar .. "\n" .. string.format("%.1f", h))
end) print(reset .. offset .. bar .. "\n" .. h)
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 end
vide.root(main) local program_time = os.clock()
local function step(): number
local FPS = 30
local DT = 1/FPS
repeat until os.clock() - program_time >= DT
program_time += DT
return DT
end
local function loop()
local callback = system()
while true do
local dt = step()
local x = callback(dt)
redraw_block(x)
end
end
loop()

110
test/stacktrace-test.luau Normal file
View file

@ -0,0 +1,110 @@
local vide = require "../"
do
print "============================================================="
local a = vide.source(1)
local cause_error = false
local function try_error()
if cause_error then error("uh oh") end
end
vide.root(function()
vide.effect(function()
a()
try_error()
end)
end)
cause_error = true
local ok, result = pcall(function() a(2) end)
print(result)
print "============================================================="
end
do
print "============================================================="
local a = vide.source(1)
local b = vide.source(1)
local c = vide.source(1)
local cause_error = false
local function try_error()
if cause_error then error("uh oh") end
end
vide.root(function()
vide.effect(function()
a()
b(vide.untrack(b) + 1)
end)
vide.effect(function()
b()
c(vide.untrack(c) + 1)
end)
vide.effect(function()
c()
try_error()
end)
end)
cause_error = true
local ok, result = pcall(function() a(2) end)
print(result)
print "============================================================="
end
do
print "============================================================="
local a = vide.source(1)
local b = vide.source(1)
local c = vide.source(1)
local cause_error = false
local function try_error()
if cause_error then error("uh oh") end
end
vide.root(function()
vide.effect(function()
a()
vide.untrack(function() -- todo: this trace appearing twice
b(b() + 1)
return nil
end)
end)
vide.effect(function()
b()
vide.batch(function()
c(vide.untrack(c) + 1)
end)
end)
vide.effect(function()
c()
try_error()
end)
end)
cause_error = true
local ok, result = pcall(function() a(2) end)
print(result)
print "============================================================="
end

View file

@ -1,6 +1,6 @@
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- testkit.luau -- testkit.luau
-- v0.7.1 -- v0.7.2
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
local color = { local color = {
@ -323,7 +323,7 @@ end
local function print2(v: unknown) local function print2(v: unknown)
type Buffer = { n: number, [number]: string } type Buffer = { n: number, [number]: string }
type Cyclic = { [{}]: true } type Cyclic = { n: number, [{}]: number }
-- overkill concatenationless string buffer -- overkill concatenationless string buffer
local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic)
@ -347,16 +347,19 @@ local function print2(v: unknown)
else -- is table else -- is table
local tabbed_indent = indent .. TAB local tabbed_indent = indent .. TAB
str.n += 1
if cyclic[value] then if cyclic[value] then
str[str.n] = color.gray "*cyclic reference*" str.n += 1
str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`)
return return
else else
cyclic[value] = true cyclic.n += 1
cyclic[value] = cyclic.n
end end
str[str.n] = "{\n" str.n += 3
str[str.n - 2] = "{ "
str[str.n - 1] = color.gray(tostring(cyclic[value]))
str[str.n - 0] = "\n"
local i, v = next(value, nil) local i, v = next(value, nil)
while v ~= nil do while v ~= nil do
@ -393,7 +396,7 @@ local function print2(v: unknown)
end end
local str = { n = 0 } local str = { n = 0 }
local cyclic = {} local cyclic = { n = 0 }
tos(v, 0, str, cyclic) tos(v, 0, str, cyclic)
print(table.concat(str)) print(table.concat(str))
end end
@ -455,7 +458,7 @@ return {
return BENCH, START return BENCH, START
end, end,
print2 = print2, print = print2,
seq = shallow_eq, seq = shallow_eq,
deq = deep_eq, deq = deep_eq,

File diff suppressed because it is too large Load diff

13
todo.md
View file

@ -1,13 +1,4 @@
# todo # todo
- property binding optimization - improve error traces
- would no longer allow `cleanup()` usage in binding scopes - prevent redundant re-eval of nodes in a complex diamond graph
- solution to nested reactivity, see: SolidJS stores
- optimize wide graph updating
- implement from solid:
- Portal
- batch
- optimize `indexes()` double-diffing
- define behavior of deriving a source within a derived source
- define behavior of node destruction under a root that has a child in another root
- improve crash course, some sections feel like information dumps

View file

@ -2,7 +2,7 @@
name = "centau/vide" name = "centau/vide"
description = "A reactive Luau library for creating UI. " description = "A reactive Luau library for creating UI. "
license = "MIT" license = "MIT"
version = "0.1.0" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
include = ["default.project.json", "LICENSE", "src"] include = ["default.project.json", "LICENSE", "src"]