Fix stale and clobbered state in Svelte applications#22382
Conversation
|
Before I finish reading, there is a lot, I remember @stwlam does not want documents at all to go in the state, and only wants plain objects for actual reactiveness. So it looks like that's something that slipped in. Wouldn't a document, which would be the same reference, not trigger reactivity? |
|
Wouldn't |
|
Wasn't aware of not wanting documents in state. Attribute Builder (which I did) is the only one that currently has documents in state (ancestry, background, class), and that was before this PR. I can convert this to draft and mark it ready again after cleaning that up. On the reactivity question specifically: with the old You're right that The minimal version (no mixin change) requires three per-app disciplines:
Point 3 is what fixes bug 2 without touching the mixin. The clobbering isn't caused by So the minimal version is genuinely smaller: no mixin change, no What the mixin rework adds is robustness rather than correctness:
The tradeoff is correct-by-construction vs correct-by-discipline. The minimal version is correct as long as three unwritten rules hold forever, and two of them (no documents, mutated fields out of I lean toward the rework, because the discipline has already slipped here: warnings sat unfixed, documents are in state, the trade bug is live. The rework doesn't enforce the three rules, it won't stop a document going into state, but it makes a slip contained rather than silent: a document in state becomes coarse-but-correct instead of silently stale, and |
I think our intent was that a re-render to a prop shouldn't occur if a value hasn't updated. The problem with the documents in state is that they are not reactive and thus we didn't want them in state. They're exclusively top level properties to invoke stuff on. However, if it turns out that states done our current way aren't reactive anyways, then perhaps a full sweep of all properties on the rerender, allowing documents to work, is a good idea, I just don't think that's the case? I have not poked into this as much as you, so please understand that I'm trying to break down what you're saying because its a lot to absorb. It looks like the main benefit over using $derived is the case where someone writes to a state prop from svelte? So correct me if I'm wrong, so long as we use $derived, don't put documents in state, and somehow block writing to the state from the sheet, the bugs are resolved? Or is there something I'm missing.
If I remember right, the warning did not exist when these applications were written. But the other two (documents/should be read only) are valid points. We should find a way to enforce them. Am I with you so far? |
|
No worries! I understand it's a lot that I'm dropping here (my head has been swirling a bit going through all this). Yeah, you've basically got it. A few things to tighten up. The warning point is fair, if One thing on enforcement, since the rework only does half of what you'd want there. Read-only it does handle: with Your three-part summary is close. The only part I'd adjust is "block writing to state from the sheet", it's a bit too narrow, it only covers writes from the component. Those fields also get written from TS that isn't the sheet. trade-dialog's On "main benefit over using $derived": worth flagging that the rework still uses On "are states done our current way reactive anyways", I think we actually agree: plain-object state is reactive, and documents aren't, which is the reason you already gave for keeping them out of state. The measurement just backs both up, The one nuance worth pulling out: that plain-object reactivity is already coarse. And that coarseness is the same thing route 2 in the description is getting at. It comes from I've got to get going for tonight, but we can keep discussing here or you can hit me up in the discord (@chromaticpenguin) if you'd prefer. |
|
Sure. I'm poking you in part because I think stwlam was considering a rewrite of the mixin anyways, and having data on the table is useful even if we don't go with this solution.
So if I gather this right, my concern was that we're losing fine grained reactivity with this approach (for coarse instead), but you're saying that we don't have that for anything that isn't a primitive already. It looks like ideally then it'd be better to try to fix that so we do have fine grained reactivity rather than throwing it away so that documents work. That'll come in handy for sheets I think. If I remember right coarse grained reactivity is still better and faster than handlebars, but we might as well try to win it all if we can.
I don't either, its more that I wanted to frame the requirements for potential solutions. The only thing that comes to mind for "no writing into state from sheet" is readonly types or some sort of Object.freeze solution. I don't have a good idea for blocking documents with an error yet. |
|
Yeah, makes sense. Happy for it to feed into whatever Shark does with the mixin even if this exact version isn't what lands. Yes, object-prop reactivity is already coarse today, so the rework isn't giving up fine-grained behavior we had. Svelte tests object changes by reference, Now that I've slept on it, I'm not as confident in my hook based idea for fine-grained. But I do have some ideas for the readonly and document issues though. I'll have to dig into it a little bit more, hopefully work is slow today! Will report back, likely with more excruciating detail... |
|
Looking forward to it! Some recursive approach might work for objects, but I have no clue for arrays of objects right now. |
|
Okay, I ended digging into things way more than I expected (yes, work did end up slow yesterday lol) TL;DR
I've dug into the hook idea, it's shakier than I made route 2 sound. The sketch is the app holding a long-lived model and patching slices from Hooks.on("updateActor", (actor, changes) => {
if (actor !== this.actor) return;
if (foundry.utils.hasProperty(changes, "system.build")) {
this.model.build = prepareBuild(actor);
}
});The problem is And it's worth being precise about where the cost even is. I profiled it to be sure, The alternative that holds up better is per-slice input memoization: each view-model slice declares a small input footprint and rebuilds only when that footprint changed. Roughly, in const build = this.memo("build", actor.system.build.attributes, prepareBuild);
const skills = this.memo("skills", actor.system.skills, prepareSkills);and #memo = new Map<string, { footprint: unknown; output: unknown }>();
protected memo<F, O>(key: string, footprint: F, rebuild: (footprint: F) => O): O {
const cached = this.#memo.get(key);
if (cached && fu.objectsEqual(cached.footprint, footprint)) {
return cached.output as O;
}
const output = rebuild(footprint);
this.#memo.set(key, { footprint, output });
return output;
}It deep-equals the footprint against last render and reuses the cached output when they match, so the The honest catch is this is Net: fine-grained reactivity isn't the blocker here. For the 10 apps where there's no cost to remove, coarse is plainly fine. And by the profiling it isn't the lever for the character sheet either, since it optimizes the minority slice while To be clear though, the headline Svelte win lands regardless of granularity. If On Fine-grained doesn't change that surface, it lives beside it. A fine-grained model is a long-lived deep On enforcement, your two requirements: Read-only is the easy one, but worth being precise about what actually lasts. Right now Blocking documents is harder. A type-level check would actually mostly work: apps hand-write precise Some final thoughts since you raised winning it all. The three layers (core, system, mixin) aren't the same kind of fix. Core: reactive data models would be the clean fix for the fine-grained side. If prepared data were reactive, System: the majority slice ( Mixin: however we reshape it, isn't where the cost lives. Worth saying plainly so the rewrite isn't expected to deliver perf it structurally can't. |
|
I think I finally parsed this. prepareData() is known slow, and I've made attempts to speed it up, but I've noticed that a heavy chunk of that is unnecessary data model validations. Attempts otherwise have failed, even caching roll options doesn't seem to have helped much. A large chunk of what you're saying is about how slow prepareData() is. Statistic could be worth a go, it might be worth seeing if there is a case where the modifier clones could be skipped for performance for simpler statistics. So lets say we give up fine grained, and its all coarse. Is it possible for documents to update without relying on a getState() function, aka with the current approach, or is it impossible for svelte to flag it as changed? EDIT: For making it fine grained, I was really just considering a merge pass where we replace properties on existing objects recursively instead of Object.assign(). But that wouldn't solve arrays unless there is a consistent id or key prop on each object. |
|
Hey, break off this side conversation to somewhere if you intend for this to not be closed without merging. This isn't a discussion forum. |
|
Pushed the attribute-builder cleanup, no more documents in state there. On the getState in use here, yeah we could swap it for an actual getter on the mixin. // mixin
get state(): object {
return this.#state;
}// component
const { foundryApp }: ... = $props();
const data = $derived(foundryApp.state);@CarlosFdez I'll @ you on the discord when I'm looking into and messing around with merge pass/statistic optimizations/readonly/no documents in state enforcement. |
|
If getState() flags all data as changed even if its just a single field with a document then there really isn't a reason to avoid documents there. I just think the getState() syntax is pretty weird and it'd be nice to have a different way of triggering a full refresh. If there isn't, then I'm alright with the getState() thing. |
|
Agreed, the change to the attribute builder was more about consistency but I'll revert it. Quick clarification because of how you phrased things. |
|
Ah, I see. So the real thing you did was use $state.raw (which did not exist when the mixin was created) in order to trigger updates on every reassignment. Sure, that makes sense, I was operating under a fundamental misunderstanding the whole time. I'll think on the read shape independently once I circle back to svelte stuff, but I should have everything I need now, thank you. |
I was going through the Svelte build warnings and the
state_referenced_locallyones led me to explore and experiment with howSvelteApplicationMixinhands state to its root component. Turns out the mixin has a couple of latent bugs affecting every Svelte-based app. This reworks how that state flow works.TL;DR:
SvelteApplicationMixinhad two latent bugs: derivations reading stable references (Foundry documents) silently stopped updating on re-render, and user or peer state stored in thestateobject got clobbered every re-render. The visible result was trades that can't complete after any actor update. Fix: thestateobject is now strictly render-derived and replaced wholesale ($state.rawplus agetState()accessor); anything mutated from TypeScript moved to instance$stateon the app (trade-dialog and compendium-browser became.svelte.ts). Measured performance is neutral.The problems
The mixin kept one
$stateobject and didObject.assign(this.$state, freshState)each render to merge in whatever_prepareContextreturned. Two issues with that:1. Reactivity that worked by accident.
Object.assignonto a$stateproxy skips writes where the value is the same reference (===). A lot of what_prepareContextreturns is stable references, Foundry documents likeactor.ancestryin the Attribute Builder for instance. Those fields never actually triggered reactivity on re-render. It only looked fine because derivations also read a fresh object (build, again on the Attribute Builder) that did trigger, and re-reading the mutated document while re-running gave the right answer anyway. Any derivation depending solely on something likedata.ancestry.system.foowould silently go stale, with no warning.2. State clobbering.
_prepareContextreturns the whole state object, including things likeaccepted: false, on the trade dialog. On re-render,Object.assignwrites that straight over whatever the user had set. User and peer-driven state living in the state object got wiped every render.In trade-dialog these combined into something genuinely broken. The component captured
const { self, trader } = dataonce at mount (thosestate_referenced_locallywarnings). After a re-render,$state.selfwas replaced with a freshaccepted: falseobject, but the component kept reading the old one. The checkmark stayed green while$state.self.acceptedwas actually false. UI and logic silently disagree.In practice, any actor update after the dialog opens re-renders the dialog and breaks trade completion for one or both of the actors (which one depends on a few things, who had the actor update, who has already accepted, and the order in which events occur). Either the trade completes for one player and never completes on the other, or never completes for either even after both have accepted. The item doesn't transfer in either case. If neither actor is touched after the dialog opens it works fine. The bug needs at least one re-render.
Screen.Recording.2026-05-21.at.10.02.21.PM.mov
Compendium browser has a similar issue, but is dormant because it doesn't auto-re-render:
game.pf2e.compendiumBrowser.render()The fix
The
stateobject is now treated as strictly render-derived: replaced wholesale each render instead of merged, and typed$state.rawso the slot is reactive but the contents aren't. Components read it through agetState()accessor inside a$derived, so they always see the current object instead of a captured-once reference.The key consequence of
$state.raw: mutatingthis.$state.foo = barno longer does anything reactive. That's deliberate, it makes the wrong thing fail loudly instead of silently. Anything actually mutated has to live elsewhere now.So there are three clear homes for state:
stateobject: render-derived data, rebuilt by_prepareContextevery render and never written afterward. The mixin injects agetState()accessor as a prop; components read it inside a$derived(const data = $derived(getState())) so they always pick up the freshly-replaced object instead of a captured-once reference.$state: UI-only state the component owns (search text, expand toggles), declared with$statein the component's<script>. Already worked, unchanged.$stateon the app class: state mutated from TypeScript that must survive re-renders. For example trade-dialog updating when the other player accepts or changes their offer, or compendium-browser'sopenTab()when an actor sheet or the sidebar tells the browser to jump to a tab. Declared as$statefields on the app itself, which is why those files have to become.svelte.ts. Components read it straight off the instance through thefoundryAppprop (foundryApp.selfAccepted).8 of 10 apps only needed the
getState()swap; they were already just consuming render-derived data. Two needed instance state:selfItems,traderItems,selfAccepted,traderAcceptedare now$statefields onTradeDialog. The other player's trade updates (#onUpdate) and the component both mutate the same instance;_prepareContextcan't touch them. It also overrides the mixin's$stateaccessor with a typed one, since#setGiftDatastill assignsthis.$statedirectly and the override gets that write shape-checked (an app that never touchesthis.$statefrom TS, like compendium-browser, doesn't need this).activeTabNameandresultListmoved to instance$state.openTab()(called from actor sheets and the sidebar) and the tab nav in the component both writeactiveTabName, so it can't live in the render-replacedstateobject.Also cleaned up the
state_referenced_locallyand a11y warnings in the touched components. Svelte build warnings drop from 8 to 0 (surpressed an intentional one infilter-container).Performance
Neutral to slightly positive.
$state.rawskips the recursive proxying that plain$statedoes, so the render-derived state object is no longer wrapped in nested proxies; for the larger ones that's a small win on every access. Replacing the object wholesale is a single assignment instead of the old per-keyObject.assignloop.The one thing worth calling out: wholesale replacement invalidates every derivation reading the state each render. Before, only derivations reading a field that got a fresh reference re-ran; ones reading stable references (like
data.ancestry) didn't, which is exactly problem 1. So "all derivations re-run now" mostly means the ones that were silently not updating finally do. The genuinely new cost is small: derivation re-runs are cheap, Svelte still diffs the DOM so a derivation that recomputes to the same result costs nothing downstream, and renders only happen when Foundry data actually changes, not on a loop.I measured this on the attribute builder to be sure. With the builder open, 5 actor updates: render count was 5 before and after (we don't render more often); a derivation reading
data.buildre-ran 5 times before and after (no regression); a derivation reading onlydata.ancestryre-ran 0 times before and 5 times after. That 0-to-5 is the whole delta, and it's the bug being fixed: the derivation that was silently never updating now correctly does.Most of this matters more later. The apps in this PR are small dialogs and pickers with modest state, so wholesale replacement is fine for them. Actor sheets are where it gets real: large nested state, more frequent re-renders, and
_prepareContextalready doing a lot of work. Regardless: the headline Svelte benefit, only the changed part of the sheet repainting instead of the whole thing, comes from Svelte's DOM diffing and is independent of the state model.Fine-grained invalidation (for later)
The model in this PR is coarse: every derivation reading the state re-runs each render. Again, that's effectively free for these apps. Actor sheets will be bigger and re-render more often, so here are the options for going fine-grained and why none are worth doing yet, as reference for when it comes up.
First, why the old code couldn't be both. Foundry documents are passed by stable reference and mutated in place. Fine-grained invalidation needs a changed field to arrive as a fresh reference and an unchanged field to keep its old one. A Foundry document gives neither guarantee: the reference is the same whether or not its contents changed, so a
===check can't tell the two apart. That's the root of problem 1.There are two ways to fix that properly.
Route 1: deep-equal memoization. Have
_prepareContext(or the mixin) deep-compare each field of the new state against the previous render's, keep the old reference where equal and use a fresh one where changed. A deep$statewith per-field assignment would then invalidate correctly and finely. The problem is it trades one cost for another: instead of re-running the derivations, you deep-compare the whole state every render. On a large sheet that comparison costs about the same as the re-derivation it's trying to avoid, so it doesn't actually help.Route 2: hook-driven granular state. Stop rebuilding state from
_prepareContexton every render. Keep a long-lived reactive model on the app and patch only the affected$statefields in response to Foundry'supdateActorandupdateItemhooks, which carry a precise diff of what changed. Reactivity is then driven by what actually changed: no deep-compare, no coarse invalidation, and correct because each patch targets exactly the changed paths. This is genuinely fine-grained and correct. The cost is that it's a different data-flow architecture, not a mixin change.On stores for route 2: Svelte 5 has largely superseded them. The official guidance is that shared reactive state should be a
$stateobject in a.svelte.tsmodule (what the rest of this work uses), with stores kept mainly for complex async streams. So route 2's model should be$state, not stores. The one store feature that stays genuinely relevant is the start/stop notifier (writable(initial, (set) => { ...; return cleanup; })): it registers a subscription on first use and tears it down on last, a tidy way to bridge a Foundry hook into reactivity. That's a convenience rather than a capability, since the app already manages its own lifecycle, but worth knowing if the hook side ever gets genuinely complex.Route 2 is the one worth doing eventually and route 1 isn't, the expensive part of a big sheet isn't derivation re-runs, it's
_prepareContextitself (Foundry data prep, already heavy and made worse by the HP-change clone). Route 1 leaves that cost fully in place. Route 2 removes it for small changes, because patching one field skips the full re-prepare. So the real future optimization isn't "make derivations re-run less," it's "stop re-preparing the whole sheet for a one-field change," and route 2 is how you get there.