This commit is contained in:
Andrea Fletcher 2026-08-06 04:02:36 -07:00 committed by GitHub
commit 2d1e5983c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 276 additions and 0 deletions

View file

@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.4.1] - 2026-07-11
### Added
- `tag()` action for adding CollectionService tags to instances. Supports
static strings and arrays as well as reactive sources for dynamic tag sets.
Tags are automatically removed when the surrounding scope is destroyed.
### Changed
- `branch()` is now allowed to be used within a reactive scope.

View file

@ -118,6 +118,56 @@ A wrapper for `action()` to listen for property changes.
Runs with an action priority of 1.
## tag()
A wrapper for `action()` to add [CollectionService](https://create.roblox.com/docs/reference/engine/classes/CollectionService)
tags to an instance.
- **Type**
```luau
function tag(tag: string): Action
function tag(tags: { string }): Action
function tag(source: () -> string): Action
function tag(source: () -> { string }): Action
```
- **Details**
Adds the given tag(s) to the instance using `Instance:AddTag()`. When the
surrounding scope is destroyed, any tags that were added are removed using
`Instance:RemoveTag()`.
A source (function) can be passed instead of a static value to reactively
update the applied tags. When the source updates, tags no longer present
are removed and new tags are added; tags present in both the previous and
new value are left untouched.
Multiple `tag()` actions can be composed on the same instance.
Runs with an action priority of 1.
- **Example**
Apply static tags:
```luau
create "Frame" {
tag("Clickable"),
tag({ "Draggable", "Highlightable" })
}
```
Apply a reactive tag driven by a source:
```luau
local state = source "idle"
create "Frame" {
tag(function() return "state-" .. state() end)
}
```
## 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

View file

@ -53,3 +53,8 @@ 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
changed externally.
Vide also ships a few common actions out of the box, such as `vide.changed()`
for the pattern above and `vide.tag()` for adding
[CollectionService](https://create.roblox.com/docs/reference/engine/classes/CollectionService)
tags to an instance.

View file

@ -20,6 +20,7 @@ local values = require "./values"
local spring, update_springs = require "./spring"()
local action = require "./action"()
local changed = require "./changed"
local tag = require "./tag"
local timeout, update_timeouts = require "./timeout"()
local flags = require "./flags"
@ -77,6 +78,7 @@ local vide = {
-- actions
action = action,
changed = changed,
tag = tag,
-- flags
strict = (nil :: any) :: boolean,

71
src/tag.luau Normal file
View file

@ -0,0 +1,71 @@
local action = require "./action"()
local cleanup = require "./cleanup"
local effect = require "./effect"
type Tags = string | { string }
local function build_set(value: Tags): { [string]: true }
local set: { [string]: true } = {}
if type(value) == "string" then
set[value] = true
else
for _, name in value do
if type(name) ~= "string" then
error("tag() expects a string or an array of strings", 0)
end
set[name] = true
end
end
return set
end
local function tag(value: Tags | () -> Tags)
return action(function(instance)
if type(value) == "function" then
local current: { [string]: true } = {}
effect(function()
local new_set = build_set((value :: () -> Tags)())
for name in current do
if not new_set[name] then
instance:RemoveTag(name)
end
end
for name in new_set do
if not current[name] then
instance:AddTag(name)
end
end
current = new_set
end)
cleanup(function()
for name in current do
instance:RemoveTag(name)
end
end)
else
local set = build_set(value)
for name in set do
instance:AddTag(name)
end
cleanup(function()
for name in set do
instance:RemoveTag(name)
end
end)
end
end)
end
type Action = typeof(action(nil :: any))
return tag :: ((tag: string) -> Action)
& ((tags: { string }) -> Action)
& ((source: () -> string) -> Action)
& ((source: () -> { string }) -> Action)

View file

@ -66,6 +66,7 @@ local Instance = {} :: any do
children: { Data },
changed: { [string]: RBXScriptSignal },
properties: { [string]: unknown },
tags: { [string]: true },
destroying: RBXScriptSignal,
class: string,
type: "Instance"
@ -158,6 +159,7 @@ local Instance = {} :: any do
children = {},
changed = {},
properties = {},
tags = {},
class = class,
destroying = Signal.new() :: any,
type = "Instance" :: "Instance"
@ -211,6 +213,29 @@ local Instance = {} :: any do
return data.changed[property]
end
function methods.AddTag(userdata: userdata, tag: string)
if type(tag) ~= "string" then error("tag must be a string", 2) end
get_data(userdata).tags[tag] = true
end
function methods.RemoveTag(userdata: userdata, tag: string)
if type(tag) ~= "string" then error("tag must be a string", 2) end
get_data(userdata).tags[tag] = nil
end
function methods.HasTag(userdata: userdata, tag: string): boolean
if type(tag) ~= "string" then error("tag must be a string", 2) end
return get_data(userdata).tags[tag] == true
end
function methods.GetTags(userdata: userdata): { string }
local out = {}
for name in get_data(userdata).tags do
table.insert(out, name)
end
return out
end
function methods.Destroy(userdata: userdata)
local data = get_data(userdata);
Signal.fire(data.destroying)

View file

@ -24,6 +24,7 @@ local vide = require "../../vide"
local spring = vide.spring
local action = vide.action
local changed = vide.changed
local tag = vide.tag
local apply = vide.apply
local step = vide.step
local graph = require "../../vide/src/graph"
@ -2340,6 +2341,122 @@ TEST("changed()", wrap_root(function()
end
end))
TEST("tag()", wrap_root(function()
do CASE "apply single tag"
local frame = create "Frame" {
tag("MyTag")
}
CHECK(frame:HasTag("MyTag"))
CHECK(#frame:GetTags() == 1)
end
do CASE "apply multiple tags"
local frame = create "Frame" {
tag({ "A", "B", "C" })
}
CHECK(frame:HasTag("A"))
CHECK(frame:HasTag("B"))
CHECK(frame:HasTag("C"))
CHECK(#frame:GetTags() == 3)
end
do CASE "compose multiple tag() actions"
local frame = create "Frame" {
tag("A"),
tag("B")
}
CHECK(frame:HasTag("A"))
CHECK(frame:HasTag("B"))
end
do CASE "remove tags on scope destroy"
local _, frame, destroy = root(function(destroy)
return create "Frame" {
tag({ "A", "B" })
}, destroy
end)
CHECK(frame:HasTag("A"))
CHECK(frame:HasTag("B"))
destroy()
CHECK(not frame:HasTag("A"))
CHECK(not frame:HasTag("B"))
end
do CASE "reactive single tag"
local current = source("A")
local frame = create "Frame" {
tag(current)
}
CHECK(frame:HasTag("A"))
CHECK(#frame:GetTags() == 1)
current("B")
CHECK(not frame:HasTag("A"))
CHECK(frame:HasTag("B"))
CHECK(#frame:GetTags() == 1)
end
do CASE "reactive multiple tags"
local current = source({ "A", "B" })
local frame = create "Frame" {
tag(current)
}
CHECK(frame:HasTag("A"))
CHECK(frame:HasTag("B"))
CHECK(#frame:GetTags() == 2)
-- preserve "A", remove "B", add "C"
current({ "A", "C" })
CHECK(frame:HasTag("A"))
CHECK(not frame:HasTag("B"))
CHECK(frame:HasTag("C"))
CHECK(#frame:GetTags() == 2)
end
do CASE "reactive tag removed on scope destroy"
local _, frame, destroy = root(function(destroy)
local current = source("A")
return create "Frame" {
tag(current)
}, destroy
end)
CHECK(frame:HasTag("A"))
destroy()
CHECK(not frame:HasTag("A"))
CHECK(#frame:GetTags() == 0)
end
do CASE "reactive tag derived from source"
local name = source("hello")
local frame = create "Frame" {
tag(function() return "prefix-" .. name() end)
}
CHECK(frame:HasTag("prefix-hello"))
name("world")
CHECK(not frame:HasTag("prefix-hello"))
CHECK(frame:HasTag("prefix-world"))
end
end))
TEST("batch()", wrap_root(function()
do CASE "evaluation deferred"
local a = source(0)