Shared state
Cross-widget wiring covers the mechanism: localStorage envelopes wrapped in a Vue ref by useSharedStorage(). This page is the contract for each envelope: what's inside, who reads it, who writes it, and the one rule that trips people up, not every reader is allowed to write every field.
Why this exists
Every GomaPulse widget is its own custom element with its own Shadow DOM, its own createApp(), and its own createPinia() instance (packages/core/src/createWidgetElement.js). Two <goma-events-horizontal> elements on the same page do not share a store, and neither do <goma-events-horizontal> and <goma-betslip-floating>: there is no shared Vue tree to reach across. CustomEvents (covered on the wiring page) solve reactions ("when X happens, do Y") but they are fire-and-forget: a <goma-betslip-floating> that mounts a second after an odds tap never sees the event that carried the selection. Anything that has to survive a widget mounting late, a page reload, or a second browser tab needs a channel with memory. @gomagaming/sports-domain uses localStorage for that, through one shared primitive: useSharedStorage(key, initial, { version }) (packages/sports-domain/src/shared-state/useSharedStorage.js).
The storage key is always goma: + key + :v + version. Three layers keep every instance that asked for the same key in sync:
- An in-module bus, for widgets that share one loaded copy of the
useSharedStorage.jsmodule (the common case, every widget in a bundler's dedup graph resolves to the same@gomagaming/sports-domainmodule instance). Updates apply synchronously, same microtask. - A
BroadcastChannel(one per key), for instances in another realm/worker of the same origin, or same-tab widgets that for some reason are not deduped to one module instance. - The
storageevent, which only ever fires in other browser tabs (never the tab that wrote the change) and is what makes the sync survive a reload or reach a second tab.
If localStorage throws on the availability probe (privacy mode, quota exceeded, no window at all, SSR), useSharedStorage silently falls back to a plain in-memory ref. The widget still works; it just stops persisting or synchronising with anything outside its own instance.
Writes are persisted by a watcher on Vue's pre queue, so a write made while the owning scope is being torn down never reaches storage: the watcher is disposed in the same tick. For that one case the module exports flushSharedStorage(ref), which runs the same persist-and-fan-out synchronously. Use it only in a teardown path; ordinary writes need nothing.
The four envelopes
Three of these run through useSharedStorage. The fourth (goma:filters-settings:v1) does not, and behaves differently; see its own section below before assuming it works like the other three.
These four are the whole public contract. useSharedStorage.test.js also exercises the primitive against throwaway keys (goma:test:v1, goma:test:v3): those are test fixtures only, not read or written by any shipped widget.
goma:betslip:v4: the shared betslip
{
selections: PersistedSelection[],
activeBetslipId: string,
stake: number,
activeTab: 'single' | 'multiple' | 'system',
oddsChangeBehavior: 'accept-any' | 'accept-higher' | 'never-accept',
systemBetTypeId: string | null,
selectedBonusWalletId: string | null,
selectedBonusWalletIdBySelection: Record<string, string | null>,
selectedBonusType: { walletId: string, type: string } | null,
selectedBonusTypeBySelection: Record<string, { walletId: string, type: string }>,
}Address the envelope through the exported constants rather than a literal: the key moves when the shape does:
js
import { BETSLIP_STORAGE_KEY, BETSLIP_STORAGE_VERSION } from '@gomagaming/sports-domain'
JSON.parse(localStorage.getItem(BETSLIP_STORAGE_KEY))Upgrading from goma:betslip:v3
v4 replaced the acceptOddsChange boolean with oddsChangeBehavior. The boolean could only express two of the three policies the provider accepts, and it carried the opposite sense to its name: true was submitted as ACCEPT_HIGHER. The version moved rather than the meaning changing under the same key, so a page still running v3 keeps reading the envelope it wrote.
Nothing is required of a host on upgrade. A v3 envelope (along with v2 and the older split keys) migrates on the first widget mount, carrying the slip, stake, tab and bonus selections across. Only the odds field changes, and every v3 envelope resolves to 'accept-higher':
| v3 envelope | was submitted as | v4 resolves to |
|---|---|---|
acceptOddsChange: true (the default) | ACCEPT_HIGHER | 'accept-higher': identical |
acceptOddsChange: false | ACCEPT_ANY | 'accept-higher' |
The second row is the only behavioural change, and it is a correction: that setting was offered under a control reading "Approve higher odds" while submitting ACCEPT_ANY, which accepts a DROP in odds too. A player wanting that now selects it explicitly, and the selector offers 'never-accept' as well: a policy the boolean could not represent at all.
A host reading acceptOddsChange from storage should read oddsChangeBehavior instead. The store still exposes the boolean, derived from the policy (true ⇔ 'accept-any'), so widget-level code that sets it keeps working.
Schema and field lists live in packages/sports-domain/src/shared-state/betslipStorageSchema.js. Each PersistedSelection carries its identity fields (outcomeId, bettingOfferId, eventId, eventName, marketId, marketName, outcomeName, sportId, competitionId, homeParticipantName, awayParticipantName, eventDate, tournamentName, idfosport, venueId, venueName, bettingTypeId, eventPartId, tournamentId, parentTemplateId, line, the bonus-eligibility ids are read by the bonus-wallets request, which scopes eligibility per selection, and line is explained below), its last-known live state (odd, isAvailable, lineMoved, isLive, so a peer that hydrates or re-mounts shows a value immediately instead of a blank row until its own feed ticks), and the user-typed field (stake, per-selection in single mode). Pure UI flash flags (priceUp/priceDown) are stripped before every write: they're re-derived locally from a watch on odd, not carried on the wire.
line is the market's line as it stood when the player tapped: a handicap's 12.5, a total's 2.5, null for the markets that have none. It travels as identity rather than live state on purpose: it says WHICH bet the row is. A betslip compares it against the market to tell a line that moved during the game (the provider rewrites the same market and re-prices the same offer) apart from an ordinary price tick, and refuses to place a leg whose line moved until the player picks the new one. That verdict is carried as lineMoved: true beside isAvailable: false, so every reader can tell the two apart: the leg is blocked because its line moved, not because the match went off the board. The betslip row says "Line changed" rather than "Selection unavailable", and goma:bet-failed lists it under movedLineSelections rather than unavailableSelections.
System bets share the choice, not the catalog. activeTab has a third value, 'system', alongside 'single' and 'multiple', and the envelope carries systemBetTypeId: the id of the system-bet type the player picked (e.g. "3 of 4"). What does not cross the wire is the list of system-bet types on offer: that catalog is derived from the current selections (fewer or more legs change which system types are even valid), so it can go stale the moment a peer widget's leg set differs from the one that produced it. Each betslip widget fetches its own catalog. Via packages/sports-domain/src/composables/betting/useBetslipSystem.js, kept deliberately separate from the Multiple tab's combinability machinery in useBetslipBettingOptions, and only the player's resulting choice (systemBetTypeId) is shared.
Bonuses share the pick, not the catalog: same rule, one more field. The player can apply a bonus to a bet; at most one applies at a time. The envelope carries that choice as selectedBonusWalletId (the slip-level pick, used in Multiple and System mode) and selectedBonusWalletIdBySelection (a map keyed by outcomeId, used in Single mode, each leg there is its own bet, so each carries its own bonus). The bonus type of each pick ('freeBet', 'oddsBoost', ...) travels beside it as selectedBonusType and selectedBonusTypeBySelection: the place-bet request flags a free bet from it when the wallets response of the moment no longer lists the picked wallet. Each record holds the wallet id it was recorded for, and a reader uses it only for that same id. These two fields are additive and optional, so the envelope version did not move: a reader that does not know them ignores them, and a build that predates them never writes them. An older build that changes a pick leaves the old record behind, but its walletId no longer equals the id being sent, so it cannot mark the new pick as a free bet. What does not cross the wire is the list of bonuses the player is eligible for: that list is fetched per widget (each betslip instance calls the bonus-wallets endpoint itself, or reads a host-fed list, depending on bonusMode), so a sidebar and a floating betslip on the same page can each hold their own copy of "what's on offer" while agreeing on what's actually applied. The feature is off until a widget sets bonusEnabled: a betslip that hasn't opted in still passes both fields through as part of the full envelope sync it already performs (so a sibling that has opted in keeps working), but it never fetches a bonus list of its own, renders anything bonus-related, or lets a mirrored pick reach its own place-bet payload, which re-checks bonusEnabled before reading either field. The envelope version is unchanged: still v3.
The slip-level stake belongs to the slip it was typed for. The envelope's root stake (the Multiple / System figure) is persisted so a sidebar and a floating betslip on one page agree on it, and it survives a reload with the selections it was typed for. It does not survive the slip being emptied: when the last leg is removed (by its row's X, by single-mode placement removing each placed leg, or by a peer widget deselecting the last card) the stake is zeroed, and a widget hydrating an envelope that holds a stake but no selections discards the stake. Dropping to one leg keeps it, since that slip still exists; the Multiple stake returns with the next leg. Clearing the stake of an empty slip is the one state field an identity-only widget (subscribeToState: false) writes: it is an invariant of the envelope rather than a value anyone chooses, and without it a page carrying no betslip widget could empty and refill the slip with the stake untouched. Leaving the next betslip to hydrate a figure nobody typed for those selections. Without this rule a figure typed once greeted every later slip on every later visit, on the Multiple tab, the moment a second leg landed.
Live odds themselves never ride this envelope: the outbound watcher in useSharedBetslipSync is keyed on selection identity + stake, not deep-watched, so an odds tick updates the local Pinia store but does not trigger a write. Each betslip widget gets its live odds from its own WAMP subscription; the shared envelope only needs to be right at hydration time.
Write-role gating. Every widget that touches the betslip calls useSharedBetslipSync({ subscribeToState }) (packages/sports-domain/src/shared-state/useSharedBetslipSync.js). The flag decides whether the widget's mapState also mounts the state watchers (stake, activeTab, oddsChangeBehavior, activeBetslipId, systemBetTypeId, selectedBonusWalletId, selectedBonusWalletIdBySelection, selectedBonusType, selectedBonusTypeBySelection) on top of the selections watchers, which always run:
subscribeToState | Widgets | What they read/write |
|---|---|---|
false (identity-only) | <goma-events-horizontal>, <goma-competitions-list>, <goma-favorites>, <goma-game-details>, <goma-hero-card>, <goma-highlight-card>, <goma-multi-banners>, <goma-pros-choice>, <goma-suggested-bet> | Read and write selections only. Never touch stake / activeTab / oddsChangeBehavior / activeBetslipId / systemBetTypeId / selectedBonusWalletId / selectedBonusWalletIdBySelection / selectedBonusType / selectedBonusTypeBySelection. |
true (full envelope) | <goma-betslip-floating>, <goma-betslip-sidebar> | Read and write everything, including slip-level state. |
Confirmed directly against the call sites: nine widgets pass { subscribeToState: false } and two call useSharedBetslipSync() with no argument or { subscribeToState: true } (the default is true).
Why the split exists, concretely. Every widget that calls useSharedBetslipSync creates its own private Pinia betslip store the moment it mounts, at the schema defaults (stake: 0, activeTab: 'multiple'). The state-out watcher (stake / activeTab / oddsChangeBehavior / activeBetslipId / systemBetTypeId / selectedBonusWalletId / selectedBonusWalletIdBySelection) is only ever registered when subscribeToState is true: for the nine identity-only widgets it doesn't exist at all. If it did, any local change to that widget's own (unused) stake/activeTab. Including one caused by reading a peer's state back in, since state-in and state-out share the same store fields. Would write straight over whatever a real betslip widget's user had typed, the next time anything nudged those fields. That's the concrete "my stake keeps resetting" symptom the write-role split exists to prevent: a card-only widget has no stake UI of its own, so a host debugging it would have no visible cause to point at. The code comment in betslipStorageSchema.js calls this "role-gated apply/write": the nine identity-only widgets don't render stake/tab/accept-odds-change, so they must neither react to peer changes in those fields nor overwrite them.
A second path feeds the same envelope: useSharedBetslipSync also attaches a window listener for goma:outcome-select (packages/sports-domain/src/shared-state/useSharedBetslipSync.js), so a host that dispatches that event itself (with no GomaPulse card widget on the page) still populates the slip. If the dispatched detail is missing bettingTypeId, the sync logs one console warning per outcome id (not per-event, so a host that fixes one emitter still hears about the next) and the row is repaired later, in place, the moment a richer snapshot for the same outcomeId arrives over the shared envelope from a widget that does resolve the full market.
goma:favorites:v1: the shared favourites cache
{ competitions: string[], events: string[] }Ids are stored as strings (packages/sports-domain/src/shared-state/useSharedFavoritesSync.js). That's not a style choice: EveryMatrix tournament and event ids are 18-digit snowflakes that exceed Number.MAX_SAFE_INTEGER, so passing one through Number() silently corrupts it.
Written by every favourite-star toggle across the family, gated by packages/sports-domain/src/shared-state/favoriteGate.js's guardedFavoriteToggle: logged out, the star emits goma:login-required and does not write; logged in, it toggles and emits goma:favorite-toggle. The writers are: the shared game-card components under packages/ui/src/components/GameCards/ (LiveRegularOdds, PreLiveRegularOdds, MarketRegularOdds) via useGameCardsTallOdds's toggleEvent, plus the competition stars that call toggleCompetition directly. OutrightRegularOdds' own star (a tournament card favourites the competition, not an event), <goma-favorites>'s section header (FavoriteCompetitionSection.vue), <goma-competitions-list>'s competition-detail header (CompetitionDetailHeader.vue), and <goma-events-horizontal>'s top-bar competition star (EventsSectionHeader.vue). All gated the same way behind guardedFavoriteToggle. <goma-hero-card>'s star writes the event set the same way.
Read by every widget that renders those same cards or headers: <goma-events-horizontal>, <goma-competitions-list>, <goma-favorites>, <goma-game-details>, <goma-hero-card>.
A related but separate mechanism sits on top of this cache: useFavoritesServerSync() (packages/sports-domain/src/shared-state/useFavoritesServerSync.js) is a page-level singleton that diffs this envelope against the EveryMatrix user-data-service over REST and pushes the delta: GET on first arm, POST/ DELETE on every subsequent toggle. It "arms" itself from whichever widget on the page first supplies favoritesApiBaseUrl + an operator id (via each widget's own config, or through useFavoritesPersistence() for widgets like <goma-events-horizontal> that don't otherwise need a favourites REST client), and it does not require <goma-favorites> to be mounted. This is the part of favourites that talks to a server; the shared-cache envelope above is what makes the star's on/off state consistent across widgets and tabs regardless of whether that REST sync is armed at all.
goma:session:v1: the resolved player session
Persisted from a single in-tab singleton ref (packages/sports-domain/src/shared-state/useSharedSession.js), not from a fresh useSharedStorage() call per widget: the module keeps exactly one ref per tab (getSessionRef()), shared by every useSharedSession() caller in that tab, so same-tab propagation is synchronous rather than routed through a BroadcastChannel. Cross-tab sync still rides the storage event, the same as the other two envelopes.
The envelope shape (DEFAULT in that file) carries sessionId, userId, username, sessionToken, a status ('idle' | 'valid' | 'invalid'), resolvedAt, and the death-note pair invalidReason / invalidSessionId / invalidationId used to drive goma:session-expired. It also keeps retiredSessionIds: the last few session ids the envelope has moved away from (replaced by a new login, or ended by a sign-out, also one in another tab). A rejection that names one of these ids is late news about an ended session, for example from a player stream that was still open, and does not end the current session. A rejection of any other id, including a new session id that is still being checked, ends the session as usual. An id that a widget starts to check again (the host hands the same id over once more) is taken off the list first, so its rejection counts. "Session-aware" here means any widget whose tree calls useSharedSession(): directly, or transitively through a shared composable such as useGameCardsTallOdds. Every one of them reads and commits into this one ref: <goma-favorites>, <goma-hero-card>, <goma-competitions-list> (its detail header), <goma-events-horizontal>, <goma-my-bets> (via useSession(), which wraps useSharedSession()), <goma-game-details> (transitively, its header calls useGameCardsTallOdds, the same shared composable the favourites star uses), and the betslip's own place-bet + balance composables in @gomagaming/sports-domain. Whichever widget on the page resolves the login first is the one every other widget's isLoggedIn check sees, without a second exchange.
goma:filters-settings:v1: per-sport filter memory (not a shared-cache envelope)
This key looks like the other three but is a different mechanism, and it's worth being precise about the difference. It's written by packages/filters-settings/src/composables/useFiltersSettings.js using a plain localStorage.getItem/setItem call, not useSharedStorage(). Concretely, that means:
- No
BroadcastChannel, nostoragelistener. Only<goma-filters-settings>itself ever reads or writes this key; no other widget consumes it, so there is nothing to keep in sync live across instances or tabs. A second tab picks up a written value only on its own next read (e.g. a fresh mount), not the instant the first tab writes it. - Not a single envelope: a map keyed by sport. The stored shape is
{ [sportId]: { timeValue, marketId, oddsFilter } }. Pressing Apply writes the currently-scoped sport's entry; switching the widget'sselectedSportId(via its own config or a paired sport picker) restores that sport's saved entry, if one exists, back onto the widget's confirmed state.
So goma:filters-settings:v1 is best understood as one widget's own persisted preferences, namespaced per sport, rather than a cross-widget cache: it happens to use the same goma:<name>:v<version> naming convention as the three envelopes above, but not the sync mechanism.
Cross-tab behaviour and the same-origin constraint
For the three useSharedStorage-backed envelopes: open the same page in two browser tabs and act in one (add a betslip selection, star a favourite, log in) and the other tab reflects it within the same event loop turn or two, no reload needed. That's the storage event firing in the tab that didn't write, mirroring the change into that tab's ref without echoing it back out (each envelope's sync guards the inbound apply behind a suppress flag for exactly one microtask, specifically so it doesn't immediately re-write and re-broadcast what it just received).
All of this is scoped to the browser's storage partition for the page's origin: scheme + host + port. Two widgets embedded on pages served from different origins never see each other's writes, no matter how identical the widget config looks, because localStorage (and BroadcastChannel) simply don't cross that boundary. The same is true of a widget inside a cross-origin <iframe>: it gets its own separate storage partition, isolated from the host page and from any other same-origin widget outside the iframe. Within one browsing context, privacy/incognito modes that block localStorage fall back to the in-memory ref described above: the widget keeps working for that page load, but nothing persists past a reload and nothing reaches another tab.
Opting out
There is no config flag on any widget that toggles participation in these envelopes: a widget's role (identity-only vs full-envelope on the betslip; favourites reader/writer or not; session-aware or not) is fixed by what that widget renders, not something a host switches at runtime. In practice, the levers a host actually has are:
- Don't mount a participating widget. A widget that never renders a game card, a favourite star, or a betslip surface never calls
useSharedBetslipSync,useSharedFavoritesSync, oruseSharedSessionin the first place, so it never touches these keys:<goma-casino-games>,<goma-competition-filter>, and<goma-time-filter>are examples that depend on@gomagaming/corealone and never import@gomagaming/sports-domain's shared-state module. - Cross the origin boundary. Embedding a widget in a cross-origin
<iframe>(or on a genuinely separate domain) isolates itslocalStoragefrom every other widget on the host page, as described above. This is a structural choice about how you embed the widget, not a runtime prop. - Accept the in-memory fallback. If the deployment already runs with
localStorageunavailable (a locked-down browser profile, certain privacy modes), every envelope degrades to a private in-memoryrefautomatically: the widget stops sharing with anything, including other instances of itself, with no code change required.
If you need per-widget isolation that none of the above gives you. For example, two <goma-betslip-floating> instances on one page that must not share a slip. That isn't a scenario the shared-state layer supports today; each instance's useSharedBetslipSync() call resolves the same goma:betslip:v4 key with no per-instance namespacing.
What to read next
- Cross-widget wiring: the
goma:*event catalogue andfollow*auto-wiring; shared state is the second of the two cooperation channels described there. - Architecture: where
useSharedStorageand its three sync composables sit in@gomagaming/sports-domain's public surface. - Host-fed data: a different intake path entirely: feeding event data into a single widget instance with no socket connection at all.