Context
#284 introduced an internal usePlayhtmlSubscription primitive in @playhtml/react (packages/react/src/hooks.ts) that backs usePresence, usePageData, usePresenceRoom, useUsers, and useCursorPresences. It hand-implements the subscribe/snapshot/lifecycle pattern that React 18 ships natively as useSyncExternalStore.
We can't adopt useSyncExternalStore today because it requires getSnapshot() to return a reference-stable value until the underlying data changes. playhtml's vanilla getters allocate on every call — users.getAll() builds a fresh array, presence.getPresences() builds a fresh Map — which makes useSyncExternalStore loop.
Proposal
- Add snapshot caching to the vanilla layer:
getAll() / getPresences() / getCursorPresences() return the same reference until a change invalidates it. This is a standalone win — less allocation churn per read, and stable references let vanilla consumers use identity checks.
- Replace
usePlayhtmlSubscription internals with useSyncExternalStore(subscribe, getSnapshot), shrinking the React layer to thin wrappers over a browser-standard primitive.
Constraint to preserve: every playhtml subscribe surface follows one convention — takes a callback, replays current state on subscribe, returns an unsubscribe function. That consistency is what makes both the vanilla usage pattern and the React fold-up work; new APIs should keep it.
Notes
- Pairs naturally with the transport consolidation work (simplification audit Bucket 3) since presence snapshot construction is being reworked there anyway.
- Care needed around cache invalidation and tearing semantics; this is subtle enough to deserve its own PR with tests, not a ride-along.
Context
#284 introduced an internal
usePlayhtmlSubscriptionprimitive in@playhtml/react(packages/react/src/hooks.ts) that backsusePresence,usePageData,usePresenceRoom,useUsers, anduseCursorPresences. It hand-implements the subscribe/snapshot/lifecycle pattern that React 18 ships natively asuseSyncExternalStore.We can't adopt
useSyncExternalStoretoday because it requiresgetSnapshot()to return a reference-stable value until the underlying data changes. playhtml's vanilla getters allocate on every call —users.getAll()builds a fresh array,presence.getPresences()builds a fresh Map — which makesuseSyncExternalStoreloop.Proposal
getAll()/getPresences()/getCursorPresences()return the same reference until a change invalidates it. This is a standalone win — less allocation churn per read, and stable references let vanilla consumers use identity checks.usePlayhtmlSubscriptioninternals withuseSyncExternalStore(subscribe, getSnapshot), shrinking the React layer to thin wrappers over a browser-standard primitive.Constraint to preserve: every playhtml subscribe surface follows one convention — takes a callback, replays current state on subscribe, returns an unsubscribe function. That consistency is what makes both the vanilla usage pattern and the React fold-up work; new APIs should keep it.
Notes