#Design
One principle explains most of Vision's behaviour, and in particular explains the thing people hit first:
Instances are weak. Visions are strong.
An instance is a disposable rendering of a declaration. A vision, and the values on it, are the thing that actually persists. Everything below follows from that.
#Two lifetimes
Cleanup destroys the instances and keeps the vision. Mount builds a new
set from the same declaration and the same values.
The instances Vision made are not the same objects after a remount - they are a fresh rendering that happens to look the same. An instance Vision only adopted is the same object every time. See ownership.
#The creation loop
Capture leaves you at the top with nothing built. Every trip down the left
side makes a complete set of instances; every trip up the right side throws
them away again. The vision and its values sit outside the loop entirely,
which is why a Cleanup followed by a Mount is a rebuild rather than a
restart.
The order on the left is worth reading once. Every instance exists before any
handler runs, and signals are connected last, after ready - which is why
staging a property never triggers your own PropertyChanged callback.
It also explains a number people expect to be wrong. Five writes before
Mount cost no property assignments at all, because nothing is connected
yet. The mount then applies the last value once per binding, exactly as it
would have with no writes.
#What survives a Cleanup
Measured, not asserted. A Frame Vision made, declared with
BackgroundTransparency = 0.25 and an attribute Role = "Panel", poked at by
hand while mounted:
before Cleanup |
after Mount again |
|
|---|---|---|
| the instance itself | Frame |
a different Frame |
| a declared property you overwrote by hand | 0.9 |
0.25, back to the declaration |
| a property you set that the declaration never mentions | false |
whatever a fresh source gives you |
| a declared attribute you overwrote by hand | "Touched" |
"Panel", back to the declaration |
| an attribute you added by hand | "added at runtime" |
gone |
| a declared tag | there | there, re-applied |
| a tag you added by hand | there | gone |
| a child you parented in by hand | there | destroyed with its parent |
| everything Vision connected | wired to this instance | re-wired to the new one |
| a value on the vision | "written" |
"written" |
| a property driven by that value | follows it | follows it |
Two rows deserve their footnotes.
"Whatever a fresh source gives you" is the class default for ClassName, but
the template's value for fromClone. Vision does not reset the property; the
instance is simply new.
And a declared property that a value also drives comes back at the value's
setting, not the literal beside it. Raise writes the declared fields, then
Finish re-fires every value on top. So:
{ ClassName = "Frame", BackgroundTransparency = 0.25,
event("Fade", 0.25, function(self, _, Value)
self.BackgroundTransparency = Value
end) }
after Fade(0.75), a Cleanup and a Mount, reads 0.75. The declaration
is re-applied, and then the values are re-applied over it.
#Why properties do not save
Because saving them would mean the instance, not the declaration, was the source of truth. Vision refuses that on purpose.
There should be two places to look, not three. The declaration says what the tree is made of; the values say what state it is in. Between them they are a complete account. If a remount also restored arbitrary properties nobody named, you would need the whole history of the instance as well.
Reading state back off an instance is lossy. Roblox properties are typed,
and several coerce what you give them: integer properties truncate a float,
number properties are float32 rather than Lua's float64, and some clamp to a
range. State that round-trips through a property is state you may have quietly
changed. A value is plain Lua and holds what you put in it.
Saving would fight the declaration. Vision knows exactly which keys the declaration names, so it could save those. But on the next mount it would have to choose between the saved value and the declared one, and there is no answer that is right for both a tweak you want to keep and a default you want back. Naming the state yourself removes the question.
A value cannot hold nil
Writing nil is how you read a value - Interface.Word() and
Interface.Word(nil) are the same call. So there is no way to clear a value
once it is set. Use false or a sentinel of your own for absence.
#So put state in a value
If a property should survive, it is state, and state lives on the vision:
local Card = Scope:Capture({
ClassName = "TextLabel",
Text = fromEvent("Word", "hello"),
mount(PlayerGui),
})
Card:Mount()
Card.Word("written") -- state, on the vision
Card:Cleanup() -- instances destroyed
Card:Mount() -- Text is "written" again
Writing the property directly does not survive:
local Plain = Scope:Capture({
ClassName = "TextLabel",
Text = "hello",
mount(PlayerGui),
})
Plain:Mount()
Plain:Open().Text = "written" -- a property on a disposable object
Plain:Cleanup()
Plain:Mount() -- Text is "hello" again
Both look like they do the same thing. Only one of them is state.
The rule of thumb
If losing it on a remount would be a bug, it is a value. If it is presentation that the declaration already describes, leave it a property.
#Ownership decides destruction
Cleanup destroys the instances Vision owns, and because Destroy is
recursive, everything inside them. Ownership is a property of a subtree root,
not of each instance: anything sitting under an owned instance goes with it,
whether Vision made it or not.
| how the node got its instance | owned | Cleanup does |
|---|---|---|
ClassName = "Frame" |
yes | destroys it, and everything under it |
fromClone(Template) |
yes | destroys the copy, never the template |
fromInstance(Existing) |
no | disconnects, and leaves the instance standing |
FromParent = "Child" |
no | never destroys it directly, but it dies with its host if the host is owned |
Ownership is decided per node, and is not inherited downward either. A
ClassName or fromClone child declared inside a FromParent or
fromInstance node is still something Vision made, so Cleanup destroys it,
at any depth:
Scope:Capture({
fromInstance(Shell), -- kept
{
FromParent = "Slot", -- kept, it was already there
{ ClassName = "UICorner" }, -- destroyed, Vision made it
{ fromClone(Template) }, -- destroyed, Vision made the copy
},
})
The line the rule draws is not "did Vision touch this" but "did Vision make
this" - with the caveat that a Destroy arriving from above takes everything
underneath it regardless.
#Adoption is not restoration
An adopted instance is the exception that proves the principle. Vision did not
make it, so this vision will not destroy it, and its properties survive a
Cleanup:
local Kept = Scope:Capture({
fromInstance(Guest),
BackgroundTransparency = 0.5,
})
Kept:Mount()
Guest.BackgroundTransparency = 0.8
Kept:Cleanup() -- Guest still exists, still 0.8
Kept:Mount() -- 0.5 again, the declaration reapplies
Note the last line. Adoption saves the instance from destruction; it does not make the declaration stop being the source of truth. Mounting reapplies every declared property over whatever was there.
Two things Cleanup does not do for an adopted instance:
- It does not undo what Vision wrote. Declared properties,
attributesandtagsstay exactly as the last mount left them. - It does not put it back. If the declaration carried a
mount, the instance was moved once and is left where it was moved to.
It is also still an ordinary instance. If it lives inside something another
vision owns, that vision's Cleanup destroys it, and the vision that adopted
it will not notice.
#The same principle, elsewhere
Nothing is created until Mount. Capture creates no instances, though it
does parse and validate the declaration, so mistakes surface there.
A vision can be cloned; the copy is a second rendering, not a second
object. Clone re-reads the same declaration table and copies the values
across. It shares that table with the original rather than snapshotting it, so
mutating the table between two clones changes what the second one builds. A
vision that adopts with fromInstance refuses to clone at all - there is only
one of that instance, which is what fromClone is for. See
sleeping and cloning.
A guest holds its host, never the reverse. Under inject and receive, a host keeps no list of what was grafted into it, so cleaning a host cascades to its guests, but remounting a host brings back only the host. Declared children are different: they are part of one declaration, and a remount rebuilds the whole subtree.
Connections belong to the mount, not the vision. Everything Vision
connected is disconnected by Cleanup and wired again on the next Mount,
against the new instances. Your ready callbacks run again too, which is why
ready hands you a teardown registrar rather than expecting you to track one.
A cleaned vision leaves its scope. Cleanup calls Scope:Forget and
Mount calls Scope:Adopt, so while a vision is asleep a Scope:Update will
not reach it. Writing to the value directly still works, and the write lands
on the next mount.