Skip to content

v0.89.0 — Viewstate invalidation

Shipped 2026-09-18. The two [ ] items below were deferred, by name, to v0.90.0 — they are Q.13 and Q.14 of the heuristic evaluation quick fixes.

Building the roadmap page v0.88.0 wrote. The inventory found ~40 write routes — group D of that page — that tell no other client anything at all: a condition the GM adds is not in a player's picker for five minutes, a character another user has open goes stale while its token on the map updates beside it, a removed campaign member keeps a working session until something 403s. The four gaps the v0.88.0 playtest hit were each fixed by hand; this closes the rest once.

The shape, from that page's recommendation: one generic viewstate:changed hint beneath the typed events rather than instead of them (a cache patched from a push is a render without a request — token moves must not become refetches), scoped by room rather than io.emit to every socket, with one entity → query-key table on each side and a guard test that catches the next route that forgets to announce. Per-collection revisions and the /revs heartbeat — step 7 of the plan, the only part needing schema — are deliberately not in this release; they answer "did I miss a push?", and the reconnect invalidation already covers the gap that produces.

  • [x] utils/viewstateChanged.ts — the generic registry (announceViewstateChanged, onViewstateChanged) with a typed { entity, id, scope, fields?, reason? } change, and v0.88.0's two registries (characterChanged, characterPermissions) re-expressed through it without changing what either emits (ViewstateEntity is a closed union so a new entity forces a client key-table entry; data carries the typed views' own payloads off the wire so there is one fan-out path rather than two; an omitted scope reaches nobody, which is the safe failure)
  • [x] Audience resolution — scope → rooms (campaign:, user:, and the character rule from services/characterChangeRecipients), registered as one push in index.ts (a mapId with no campaign resolves through the row rather than getting a room of its own; a scope naming nobody logs rather than pushing, since a route that forgot its scope would otherwise look like a working push)
  • [x] Client listener + key table — entity → query-key families, useViewstateInvalidation mounted once in AppRouter, and v0.88.0's two hint events retired into it (both did nothing but read characterId and invalidate, so keeping them beside the generic one meant two events and two refetches per write; characterInvite:changed and the other toast/navigate events stay, since a data-free hint carries no name to put in a notification). A guard test asserts the server's entity union and the client's key table are the same set — an entity the server can announce and the client cannot act on is a push that does nothing. Factions and system traits are deliberately in neither: their hooks are useState fetches with no cache to invalidate
  • [x] Announce from the character routes: the row itself, every name-keyed sub-collection (feats, powers, attacks, spells, equipment), actions, artwork, token image, versions, create / delete / import, and the campaign link / unlink (all fifteen sub-collection routes go through afterCollectionWrite, whose v0.88.0 announce is now unconditional — it fired only when derivation wrote, which was the case that broke the writer's screen and so got noticed, while the write itself was always invisible to everyone else; a guard test walks every router.post/put/patch/delete on the characters router and fails on one that announces nothing)
  • [x] Announce from the campaign and map routes: campaign create / update / delete / change-system, member removed, invite deleted, map create / copy / delete, map layers (what these all had in common is that the thing that changed was a list, which has no per-row event to ride on — "user 1 makes map Y, user 2 clicks the map selector and doesn't see it" was never a dropdown bug. Change-system announces every campaign-scoped reference family rather than the campaign row, since it merges and deletes across all of them; a removed member is told in their own room, because they are no longer in the campaign's)
  • [x] Announce from the campaign reference data routes: conditions, campaign actions, campaign functions, property definitions, prop types, attribute groups, bars, roll tables (the slowest caches in the app at five minutes, and the ones whose staleness is hardest to recognise as staleness — a dex prop type the GM adds is unknown to every other client's dice, so 1d20+dex adds the score instead of the modifier and arrives as a wrong number rather than a missing one. Conditions is the one router mounted outside a campaign, so it reads the audience off the row and says nothing for a system default, which belongs to no campaign). System traits deferred: useSystemTraits is a useState fetch with no cache to invalidate — see the note on the next item
  • [x] Announce from the remaining routes: parties, token-level permission rows, and the user-scoped ones (messages read state, user settings, validation rule preferences, soft-delete restore / purge) — four shapes of audience in one commit: a party belongs to a campaign or to one person and the row decides; the token-level permission PUT is the one grant write with no seam (every character grant funnels through utils/characterPermissions, which has announced since v0.88.0; the token table has no such funnel); the user-scoped caches close "read in one tab, unread in the other, forever"; and a restore announces twice, because the list the row came back into and the Undelete panel it left are two different audiences
  • [ ] Factions and custom system traits — blocked on their hooks, not on the channel. useFactions, useFactionAssignments and useSystemTraits are useState/useEffect fetches with no React Query cache, so there is nothing an invalidation can reach and an entity for them would be a push that does nothing. Two atomic steps: migrate those three hooks to useQuery (no behaviour change), then add the entities, the key-table entries and the announces. Left for v0.90.0 unless the migration turns out to be small
  • [x] A source-text guard listing every write route that must announce, so the next one added fails a test rather than a playtest — every router with a POST/PUT/PATCH/DELETE must be in exactly one of three sets: it announces, it carries its own data (group A — tokens, chat, drawings: those must not become hints, a token move is a render without a request), or it is silent with a written reason, which the test requires to be a full sentence. A second check asserts every entity in the server's union is announced by something, catching the inverse failure: a key-table entry waiting for a push that does not exist. It found one on the way in — characterRelationship had a union member, a client entry, and no announce
  • [x] Rejoin the campaign room on every connect — found while starting the item below, and a live bug rather than a prerequisite: Socket.IO rooms belong to a connection, but the socket-client object is reused across reconnects, so the effects that join campaign:<id> (usePresence, MapTool) never ran again and a client that dropped once was roomless for the rest of its session. Survivable while that room carried only the relays; not survivable now that viewstate:changed goes to it. user:join has had this fix since v0.33.0
  • [ ] Scope the group-A emits to rooms — tokens, map objects, drawings, chat messages, VBL and fog shapes, vision / light-source / damage types and currencies stop going to every connected socket; the client's map filter stays as the second line. Deferred to v0.90.0, and deliberately: it is the one item here that can silently stop multiplayer sync rather than merely leave a screen stale, and it needs three things this release does not — a cached mapId → campaignId resolver (a token drag must not become a query), an emit path that stays synchronous so two position updates cannot swap order behind a promise, and token:deleted gaining a mapId it does not currently carry. The roadmap page already calls it "a separate task"; the correctness work it protects is done, and this is the cost and leak half
  • [x] refetchOnWindowFocus for the hint-only caches, and a resume check on visibilitychange after a long hide — the flag was off globally, so a tab you merely looked at again learned nothing; it now defaults on, which is cheaper than it sounds because React Query only refetches a query that is already stale, so each family's staleTime finally means what it has always claimed to. The six socket-fed caches opt out (a push keeps them right, and refetching the ~340 KB map world on every alt-tab would undo v0.31.0) and are covered instead by useResumeRefresh: a tab hidden longer than five minutes is treated as a reconnect, without waiting for the socket to work out that it missed something