Skip to content

Commit 43d1353

Browse files
authored
presence: populate playerIdentity on non-cursor rooms (#109)
* presence: write identity into dedicated awareness field on every room PresenceView.playerIdentity was sourced exclusively from the cursor client's awareness state, so remote peers in any room created via playhtml.createPresenceRoom() arrived with playerIdentity undefined. Each presence API instance now lazily writes its own identity into a dedicated __playhtml_identity__ awareness field on first use. buildViewFromState reads the cursor field first (backwards compat) and falls back to the new field, so rooms without a cursor client still populate identity for remote peers. * react/examples: add UniquePeoplePill demo for presence-room dedupe Renders unique-people count (deduped by playerIdentity.publicKey) and total-tab count from a non-cursor presence room. Verifies that remote peers in rooms created via createPresenceRoom() now arrive with playerIdentity populated, so multi-tab dedupe works. Wired into website/test/react-test.tsx for browser verification. * presence: re-arm identity write after awareness provider rebind Code review caught that caching idempotency in a closure boolean (identityWritten) survives SPA navigation, which rebuilds the cursor / main provider and creates a fresh awareness object. The boolean stayed true, so ensureIdentityWritten() became a no-op against the new awareness — reintroducing the original bug on any post-navigation room. Drive idempotency off the current awareness's local state instead. New awareness object → no IDENTITY_FIELD → write fires. Also drop IDENTITY_FIELD from SYSTEM_FIELDS: that set is only iterated against state[__presence__] keys, and identity lives at the top level, so the entry was misleading dead defense. Add two tests: - regression: rebuilt awareness gets identity re-written - contract: peer with neither cursor nor identity field returns playerIdentity undefined (not throwing)
1 parent afa13ac commit 43d1353

5 files changed

Lines changed: 316 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"playhtml": patch
3+
---
4+
5+
presence: ensure `playerIdentity` is populated on all presence rooms, not only the cursor room. Previously `PresenceView.playerIdentity` was read exclusively from the cursor client's awareness field, so remote peers in any presence room created via `playhtml.createPresenceRoom()` arrived with `playerIdentity: undefined`. Each presence API instance now writes its own identity into a dedicated `__playhtml_identity__` awareness field; `buildViewFromState` falls back to it when the cursor field is absent.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// ABOUTME: Tests that createPresenceAPI populates playerIdentity for remote peers
2+
// ABOUTME: in rooms where no cursor client is running (regression for non-cursor rooms).
3+
4+
import { describe, it, expect } from "vitest";
5+
import { createPresenceAPI } from "../presence";
6+
import type { PlayerIdentity } from "@playhtml/common";
7+
8+
const IDENTITY_FIELD = "__playhtml_identity__";
9+
10+
interface MockAwareness {
11+
clientID: number;
12+
states: Map<number, Record<string, unknown>>;
13+
getStates(): Map<number, Record<string, unknown>>;
14+
getLocalState(): Record<string, unknown> | null;
15+
setLocalStateField(field: string, value: unknown): void;
16+
on(event: string, cb: (...args: unknown[]) => void): void;
17+
}
18+
19+
function makeAwareness(clientID: number): MockAwareness {
20+
const states = new Map<number, Record<string, unknown>>();
21+
states.set(clientID, {});
22+
return {
23+
clientID,
24+
states,
25+
getStates: () => states,
26+
getLocalState: () => states.get(clientID) ?? null,
27+
setLocalStateField(field, value) {
28+
const cur = states.get(clientID) ?? {};
29+
states.set(clientID, { ...cur, [field]: value });
30+
},
31+
on() {},
32+
};
33+
}
34+
35+
function makeIdentity(publicKey: string): PlayerIdentity {
36+
return {
37+
publicKey,
38+
playerStyle: { colorPalette: ["#000"] },
39+
} as PlayerIdentity;
40+
}
41+
42+
describe("createPresenceAPI identity propagation", () => {
43+
it("writes local identity into __playhtml_identity__ on first use", () => {
44+
const awareness = makeAwareness(1);
45+
const identity = makeIdentity("pk_local");
46+
const api = createPresenceAPI({
47+
getAwareness: () => awareness,
48+
getPlayerIdentity: () => identity,
49+
});
50+
51+
// Identity not written yet (lazy)
52+
expect(awareness.getLocalState()?.[IDENTITY_FIELD]).toBeUndefined();
53+
54+
api.getPresences();
55+
expect(awareness.getLocalState()?.[IDENTITY_FIELD]).toEqual(identity);
56+
});
57+
58+
it("resolves remote peer playerIdentity from __playhtml_identity__ when no cursor field", () => {
59+
const awareness = makeAwareness(1);
60+
const localIdentity = makeIdentity("pk_local");
61+
62+
// Simulate a remote peer in a non-cursor room: they wrote IDENTITY_FIELD
63+
// but never wrote __playhtml_cursors__.
64+
const remoteIdentity = makeIdentity("pk_remote");
65+
awareness.states.set(2, { [IDENTITY_FIELD]: remoteIdentity });
66+
67+
const api = createPresenceAPI({
68+
getAwareness: () => awareness,
69+
getPlayerIdentity: () => localIdentity,
70+
});
71+
72+
const presences = api.getPresences();
73+
const remote = Array.from(presences.values()).find((p) => !p.isMe);
74+
expect(remote).toBeDefined();
75+
expect(remote!.playerIdentity).toEqual(remoteIdentity);
76+
});
77+
78+
it("prefers cursor-field identity over __playhtml_identity__ for backwards compat", () => {
79+
const awareness = makeAwareness(1);
80+
const localIdentity = makeIdentity("pk_local");
81+
82+
const cursorIdentity = makeIdentity("pk_from_cursor");
83+
const fallbackIdentity = makeIdentity("pk_from_fallback");
84+
awareness.states.set(2, {
85+
__playhtml_cursors__: { playerIdentity: cursorIdentity },
86+
[IDENTITY_FIELD]: fallbackIdentity,
87+
});
88+
89+
const api = createPresenceAPI({
90+
getAwareness: () => awareness,
91+
getPlayerIdentity: () => localIdentity,
92+
});
93+
94+
const presences = api.getPresences();
95+
const remote = Array.from(presences.values()).find((p) => !p.isMe);
96+
expect(remote!.playerIdentity).toEqual(cursorIdentity);
97+
});
98+
99+
it("identity write is idempotent across multiple API calls", () => {
100+
const awareness = makeAwareness(1);
101+
const identity = makeIdentity("pk_local");
102+
let calls = 0;
103+
const wrappedAwareness = {
104+
...awareness,
105+
setLocalStateField(field: string, value: unknown) {
106+
if (field === IDENTITY_FIELD) calls++;
107+
awareness.setLocalStateField(field, value);
108+
},
109+
};
110+
111+
const api = createPresenceAPI({
112+
getAwareness: () => wrappedAwareness,
113+
getPlayerIdentity: () => identity,
114+
});
115+
116+
api.getPresences();
117+
api.setMyPresence("page", { url: "/" });
118+
api.getPresences();
119+
expect(calls).toBe(1);
120+
});
121+
122+
it("re-arms the identity write after the awareness provider is rebuilt", () => {
123+
// Regression: SPA navigation rebuilds the cursor/main provider, which
124+
// creates a fresh awareness object with no identity field. The presence
125+
// API must write identity into the new awareness on next use.
126+
let awareness = makeAwareness(1);
127+
const identity = makeIdentity("pk_local");
128+
129+
const api = createPresenceAPI({
130+
getAwareness: () => awareness,
131+
getPlayerIdentity: () => identity,
132+
});
133+
134+
api.getPresences();
135+
expect(awareness.getLocalState()?.[IDENTITY_FIELD]).toEqual(identity);
136+
137+
// Simulate provider rebind on navigation: brand-new awareness instance.
138+
awareness = makeAwareness(2);
139+
expect(awareness.getLocalState()?.[IDENTITY_FIELD]).toBeUndefined();
140+
141+
api.getPresences();
142+
expect(awareness.getLocalState()?.[IDENTITY_FIELD]).toEqual(identity);
143+
});
144+
145+
it("returns playerIdentity undefined for peers with no identity at all", () => {
146+
// Pin the contract: missing both cursor field and identity field should
147+
// not throw; playerIdentity is simply undefined on the view.
148+
const awareness = makeAwareness(1);
149+
awareness.states.set(2, { __presence__: { tab: { id: "x" } } });
150+
151+
const api = createPresenceAPI({
152+
getAwareness: () => awareness,
153+
getPlayerIdentity: () => makeIdentity("pk_local"),
154+
});
155+
156+
const presences = api.getPresences();
157+
const remote = Array.from(presences.values()).find((p) => !p.isMe);
158+
expect(remote).toBeDefined();
159+
expect(remote!.playerIdentity).toBeUndefined();
160+
});
161+
});

packages/playhtml/src/presence.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getStableIdForAwareness } from "./awareness-utils";
66

77
const PRESENCE_FIELD = "__presence__";
88
const CURSOR_FIELD = "__playhtml_cursors__";
9+
const IDENTITY_FIELD = "__playhtml_identity__";
910
const SYSTEM_FIELDS = new Set(["playerIdentity", "cursor", "isMe"]);
1011

1112
/** Minimal awareness interface matching YPartyKitProvider.awareness */
@@ -37,6 +38,17 @@ export function createPresenceAPI(deps: PresenceDeps): PresenceAPI {
3738
return deps.getAwareness();
3839
}
3940

41+
// Write our identity into a dedicated awareness field so remote peers can
42+
// resolve playerIdentity even on rooms where no cursor client is running.
43+
// Idempotency keyed on the current awareness's local state (not a closure
44+
// boolean) so SPA navigation that rebuilds the provider — and with it the
45+
// awareness object — re-arms the write on the new awareness.
46+
function ensureIdentityWritten(): void {
47+
const awareness = getAwareness();
48+
if (awareness.getLocalState()?.[IDENTITY_FIELD]) return;
49+
awareness.setLocalStateField(IDENTITY_FIELD, deps.getPlayerIdentity());
50+
}
51+
4052
function channelFingerprint(
4153
states: Map<number, Record<string, unknown>>,
4254
channel: string,
@@ -99,7 +111,9 @@ export function createPresenceAPI(deps: PresenceDeps): PresenceAPI {
99111
| { cursor?: Cursor | null; playerIdentity?: PlayerIdentity; zone?: unknown }
100112
| undefined;
101113

102-
const playerIdentity = cursorState?.playerIdentity;
114+
const playerIdentity =
115+
cursorState?.playerIdentity ??
116+
(state[IDENTITY_FIELD] as PlayerIdentity | undefined);
103117
const cursor = cursorState?.cursor ?? null;
104118
const customChannels = (state[PRESENCE_FIELD] as Record<string, unknown>) ?? {};
105119

@@ -158,6 +172,7 @@ export function createPresenceAPI(deps: PresenceDeps): PresenceAPI {
158172

159173
return {
160174
setMyPresence(channel: string, data: unknown): void {
175+
ensureIdentityWritten();
161176
const awareness = getAwareness();
162177
const currentState = awareness.getLocalState() ?? {};
163178
const currentPresence = (currentState[PRESENCE_FIELD] as Record<string, unknown>) ?? {};
@@ -174,13 +189,15 @@ export function createPresenceAPI(deps: PresenceDeps): PresenceAPI {
174189
},
175190

176191
getPresences(): Map<string, PresenceView> {
192+
ensureIdentityWritten();
177193
return buildPresences();
178194
},
179195

180196
onPresenceChange(
181197
channel: string,
182198
callback: (presences: Map<string, PresenceView>) => void,
183199
): () => void {
200+
ensureIdentityWritten();
184201
const id = String(nextListenerId++);
185202
listeners.set(id, { channel, callback, lastFingerprint: "" });
186203
attachAwarenessListener();
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// ABOUTME: Ambient pill showing how many unique people (deduped by playerIdentity)
2+
// ABOUTME: are present in a non-cursor presence room across all of their open tabs.
3+
4+
import React, { useEffect, useState } from "react";
5+
import { usePresenceRoom } from "@playhtml/react";
6+
import type { PresenceView } from "@playhtml/common";
7+
8+
interface Props {
9+
roomName?: string;
10+
label?: string;
11+
}
12+
13+
interface Stats {
14+
uniquePeople: number;
15+
totalTabs: number;
16+
swatches: string[];
17+
}
18+
19+
function computeStats(presences: Map<string, PresenceView>): Stats {
20+
const byPublicKey = new Map<string, { tabs: number; color: string }>();
21+
let totalTabs = 0;
22+
23+
for (const view of presences.values()) {
24+
totalTabs++;
25+
const pk = view.playerIdentity?.publicKey;
26+
if (!pk) continue;
27+
const existing = byPublicKey.get(pk);
28+
if (existing) {
29+
existing.tabs++;
30+
} else {
31+
const color = view.playerIdentity?.playerStyle?.colorPalette?.[0] ?? "#888";
32+
byPublicKey.set(pk, { tabs: 1, color });
33+
}
34+
}
35+
36+
return {
37+
uniquePeople: byPublicKey.size,
38+
totalTabs,
39+
swatches: Array.from(byPublicKey.values()).map((v) => v.color),
40+
};
41+
}
42+
43+
export const UniquePeoplePill: React.FC<Props> = ({
44+
roomName = "unique-people-demo",
45+
label = "people here",
46+
}) => {
47+
const room = usePresenceRoom(roomName);
48+
const [stats, setStats] = useState<Stats>({
49+
uniquePeople: 0,
50+
totalTabs: 0,
51+
swatches: [],
52+
});
53+
54+
useEffect(() => {
55+
if (!room) return;
56+
// Each tab announces itself with a tab-id payload so the room actually has
57+
// a presence channel populated. The dedupe relies entirely on playerIdentity.
58+
const tabId = Math.random().toString(36).slice(2, 10);
59+
room.presence.setMyPresence("tab", { tabId, openedAt: Date.now() });
60+
61+
const update = () => setStats(computeStats(room.presence.getPresences()));
62+
update();
63+
const unsub = room.presence.onPresenceChange("tab", update);
64+
return unsub;
65+
}, [room]);
66+
67+
const ready = room !== null;
68+
const dedupedTabs = stats.totalTabs - stats.uniquePeople;
69+
70+
return (
71+
<div
72+
style={{
73+
display: "inline-flex",
74+
alignItems: "center",
75+
gap: 10,
76+
padding: "8px 14px",
77+
borderRadius: 999,
78+
background: "#f5f0e8",
79+
border: "1px solid #d8d0c4",
80+
color: "#3d3833",
81+
fontFamily: "system-ui, sans-serif",
82+
fontSize: 14,
83+
}}
84+
>
85+
<span style={{ display: "inline-flex", gap: 4 }}>
86+
{stats.swatches.length === 0 ? (
87+
<span
88+
style={{
89+
width: 10,
90+
height: 10,
91+
borderRadius: "50%",
92+
background: "#c4c0b8",
93+
}}
94+
/>
95+
) : (
96+
stats.swatches.map((color, i) => (
97+
<span
98+
key={i}
99+
style={{
100+
width: 10,
101+
height: 10,
102+
borderRadius: "50%",
103+
background: color,
104+
border: "1px solid rgba(0,0,0,0.1)",
105+
}}
106+
/>
107+
))
108+
)}
109+
</span>
110+
<span>
111+
<strong>{stats.uniquePeople}</strong> {label}
112+
</span>
113+
<span style={{ color: "#8a8279", fontSize: 12 }}>
114+
{ready
115+
? `${stats.totalTabs} tab${stats.totalTabs === 1 ? "" : "s"}${
116+
dedupedTabs > 0 ? ` · ${dedupedTabs} deduped` : ""
117+
}`
118+
: "connecting…"}
119+
</span>
120+
</div>
121+
);
122+
};

website/test/react-test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { SharedSlider } from "../../packages/react/examples/SharedSlider";
2727
import { LiveReactions } from "../../packages/react/examples/LiveReactions";
2828
// import { CursorOverlap } from "../../packages/react/examples/CursorOverlap";
2929
import { SharedSound } from "../../packages/react/examples/SharedSound";
30+
import { UniquePeoplePill } from "../../packages/react/examples/UniquePeoplePill";
3031

3132
const Candle = withSharedState(
3233
{ defaultData: { on: false } },
@@ -174,6 +175,15 @@ ReactDOM.createRoot(
174175
</div>
175176

176177
<LoadingStateTest />
178+
<div style={{ padding: "20px", border: "2px solid #333", margin: "20px 0" }}>
179+
<h3>Unique People Pill (presence-room dedupe)</h3>
180+
<p style={{ marginBottom: "12px", color: "#666" }}>
181+
Open this page in multiple tabs as the same browser/user — "people" stays at 1 while "tabs" climbs.
182+
Open in another browser or incognito → "people" goes to 2. Regression check for the
183+
presence-identity-on-non-cursor-rooms fix.
184+
</p>
185+
<UniquePeoplePill />
186+
</div>
177187
<Candle />
178188
<ReactionView reaction={{ emoji: "🧡", count: 1 }} />
179189
<SharedLamp />

0 commit comments

Comments
 (0)