What I’m particularly interested in is feedback from people who have built large JUCE applications and have hit limitations, pain points, or scaling issues with ValueTree.
Some questions that I’d love to discuss:
What would you redesign if you could start from scratch today?
Have you encountered performance or maintainability issues in large projects?
Do you prefer runtime flexibility or stronger compile-time guarantees?
Are there features missing from ValueTree that you’ve had to build yourself?
The intention is to gather ideas and understand what a next-generation tree-based data model for audio applications could look like.
Any feedback, criticism, or war stories are welcome.
Love the idea of transactions - that’s definitely a common issue with ValueTrees that you want to set multiple values without firing listeners for all of them! You could even add an AsyncTransaction too to have the listeners called asynchronously.
In terms of performance, I’ve actually recently done some benchmarking of juce::var vs STL alternatives and the STL types always win. A relatively quick-win could be to use a std:variant over a juce::var containing all the same types. I also found some significant performance problems with juce::Identifier so using plain juce::Strings could be better for performance too.
Another common issue is with const-correctness as taking a copy of a const ValueTree allows you to modify the underlying data. So ideally you’d use an approach where you instead pass around a std::shared_ptr<const DataTree> rather than the ValueTree style wrapper so you can restrict who can write to it.
Could it be, that what you were testing did not use `juce::Identifier` correctly? As I understand it, it has high construction costs but is O(1) in the compare operation, which seems like the correct optimisation for what it is being used in value trees?
This! Const-correctness would definitively fall in the “Do you prefer runtime flexibility or stronger compile-time guarantees?” category and would be greatly appreciated in all of my projects. Usually I write wrappers around the value trees giving me the type safety etc. back, but re-creating const-correctness would be a lot of work for a wrapper (did do it with inheritance a few times, but there was not easy nor nice solution that I came up with in my paradigm).
In my experience, having to do multiple transactions at once (for the observes not to get confused of course, the notifier usually never cares) was always fixable by choosing a “more fitting” representation of the data, like creating and setting up a new child, maybe a new “track” instance with a bunch of settings and plugins already pre-filled, and then adding that new “track” to the application model. In my cases, changing the topology always solved the “transaction” problem.
Interestingly, that actually had one exception in all my projects. Imagine the following structure
<MY_DAW_PROJECT>
...
<TRACKS>
... a bunch of tracks ...
</TRACKS>
...
</MY_DAW_PROJECT>
My mixer-view would be listing to the node `MY_DAW_PROJECT/TRACKS` for insertion and deletion of new tracks. Any controller in any part of the application holding the tracks reference can adjust this, and the mixer-view will update accordingly. Except file IO.
If I load a completely new XML file into my application at the root level of `MY_DAW_PROJECT`, this algorithm doesn’t clear and add new tracks, it deletes `TRACKS` and adds a completely new `TRACKS` object. On a UI level that would mean, that the root UI window would need to listen to `MY_DAW_PROJECT` to see if `TRACKS` is deleted and re-added to re-create the mixer-view, which makes absolutely no sense, as I know I’ll always have exactly one `TRACKS` object and the mixer-view is always part of my application. It’s not like the user is allowed to delete `TRACKS` and therefore have no mixer-view anymore etc.
For those kinds of wrappers I typically make them non-copyable and non-moveable and then share them around using std::shared_ptr. That should allow you to use std::shared_ptr<const Wrapper> to ensure the unit receiving that can’t write to the value tree.
That’s usually a good approach, yes, but it’s not easy for more mature projects where changing the structure of the ValueTree could mean having to refactor dozens of other units. Then your simple feature has a 2000 line PR!
I struggled with something similar to this in JIVE - each node of the tree has a corresponding object that listens to it, but as you say if the very root tree is completely reconstructed then none of those objects are informed of the changes. In many cases there actually weren’t many changes but you still have to completely rebuild everything from scratch.
Thanks a lot for the feedback, lot of good stuff in there.
Yes this is something i thought about, but for now i wanted to keep things simple (because async transactions opens up cans of worms… what if i async commit a new transaction while one is in flight ? which values should i see ? should these do update in order overwriting, or merging, or what ?)
This has nothing to do with value trees, it’s clear that you need to have your set of Identifier globally created and reuse the constants. Creating an Identifier every time in the place you need it is not going to scale well.
I don’t remember this exact issue with ValueTree in JUCE, but this is already in place in DataTree, transactions are mutating the object so if the DataTree is const they can’t happen
const juce::ValueTree constTree{ "Tree" };
constTree.setProperty("foo", 123, nullptr); // Compiler error
juce::ValueTree mutableTree = constTree;
mutableTree.setProperty("foo", 123, nullptr); // Allowed - and changes the state of constTree
Can I take a non-const copy of a const yup::DataTree and use it to change the shared data? Or does yup::DataTree not use shared data?
No, valueTreeRedirected() is only called when the ValeuTree instance you’re listening to is redircted to point at a new internal shared data. It isn’t called when the parent tree is redirected. E.g:
struct Listener : juce::ValueTree::Listener
{
void valueTreeRedirected(juce::ValueTree& redirectedTree) override
{
std::cout << "Redirected to "
<< redirectedTree.getType().toString()
<< "!\n";
}
void valueTreeParentChanged(juce::ValueTree& childTree) override
{
std::cout << "Parent of " << childTree.getType().toString()
<< " changed - new parent is "
<< (childTree.getParent().isValid()
? childTree.getParent().getType().toString()
: "invalid")
<< "\n";
}
};
int main()
{
// Create a child and a parent
juce::ValueTree child{ "Child" };
juce::ValueTree root{
"Root",
{},
{
child,
},
};
// Add the listener
Listener listener;
child.addListener(&listener);
// Directly redirect the child - triggers the expected callback
child = juce::ValueTree{ "NewChild" };
// Redirect the parent - neither callback is triggered!
root = juce::ValueTree{
"NewRoot",
{},
{
juce::ValueTree{ "LatestChild" },
},
};
// Child has no parent, but the listener wasn't informed!
std::cout << "Parent is "
<< (child.getParent().isValid()
? child.getParent().getType().toString()
: "invalid")
<< "\n";
}
Output:
Redirected to NewChild
Parent is invalid
I think the real issue is that you don’t get valueTreeParentChanged() even though after the parent is redirected the parent of the child node is invalid. This actually feels like a bug, @anthony-nicholls et al.
I go back and forth over whether I like the fact that ValueTree has reference semantics. I think there’s pros & cons, one of the biggest cons being that you have to teach sanitizers that passing by value isn’t a bug