From 18c1d02d431d0f1ea60be283620412ed900e39f0 Mon Sep 17 00:00:00 2001 From: Andrea Fletcher <50600470+BitwiseAndrea@users.noreply.github.com> Date: Tue, 26 May 2026 22:10:47 -0700 Subject: [PATCH] Add `tag()` action for CollectionService tags Adds a built-in `vide.tag()` action that wraps `Instance:AddTag()` / `Instance:RemoveTag()` so tags can be applied through `create()` props. It accepts a static string or array of strings, or a reactive source returning either. The reactive variant diffs the previous and new tag sets so only the delta is applied. All tags added by the action are removed when the surrounding scope is destroyed. Includes mock support for `AddTag`/`RemoveTag`/`HasTag`/`GetTags`, tests, API reference docs, and a closing note in the actions tutorial. Co-authored-by: Cursor --- CHANGELOG.md | 6 ++ docs/api/creation.md | 50 ++++++++++++ docs/tut/crash-course/12-actions.md | 5 ++ src/lib.luau | 2 + src/tag.luau | 71 +++++++++++++++++ test/mock.luau | 25 ++++++ test/tests.luau | 117 ++++++++++++++++++++++++++++ 7 files changed, 276 insertions(+) create mode 100644 src/tag.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 4170b6a..1e2b921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### 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. diff --git a/docs/api/creation.md b/docs/api/creation.md index 578b10f..34b5076 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -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() STABLE Runs a function in a new stable scope and optionally applies its result to a diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index 60e5c30..33902b3 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -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. diff --git a/src/lib.luau b/src/lib.luau index 28784b4..bee5379 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -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" @@ -75,6 +76,7 @@ local vide = { -- actions action = action, changed = changed, + tag = tag, -- flags strict = (nil :: any) :: boolean, diff --git a/src/tag.luau b/src/tag.luau new file mode 100644 index 0000000..5081a51 --- /dev/null +++ b/src/tag.luau @@ -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) diff --git a/test/mock.luau b/test/mock.luau index a1f22a6..63cbbf5 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -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) diff --git a/test/tests.luau b/test/tests.luau index af6a7f4..7a0dcaa 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -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)