Fix waypoint drag, host election, segment routing, debug panel

Waypoint drag:
- Use observeDeep instead of observe to detect Y.Map property changes

Host election:
- Extracted to host-election.ts with 8 unit tests
- Simple rule: lowest real client ID is always host, deterministic
- Always sets role (host or participant), no missing roles
- Filters server docs (empty awareness state) from election

BRouter routing:
- Route segment-by-segment like bikerouter.de (one request per
  waypoint pair), guarantees route passes through every waypoint
- Merge GeoJSON segments with accumulated stats
- 8 unit tests for segment merging and waypoint splitting

Awareness cleanup:
- Track client IDs per WebSocket connection
- Immediately remove awareness on WebSocket close

Debug panel:
- Visibility + expanded state persisted in localStorage
- Hotkey (Shift+Cmd+Option+T) toggles visibility
- Labels server docs as (server)
- All logic self-contained in YjsDebugPanel.tsx

User identity:
- Color and name persisted in localStorage across reloads

Tests: 32 total (16 existing + 8 host election + 8 routing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-23 07:30:00 +01:00
parent 2e5c31d117
commit 55806fc84a
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 673 additions and 32 deletions

View file

@ -127,11 +127,11 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
}
};
yjs.waypoints.observe(update);
yjs.waypoints.observeDeep(update);
update();
return () => {
yjs.waypoints.unobserve(update);
yjs.waypoints.unobserveDeep(update);
};
}, [yjs.waypoints, onRouteRequest]);

View file

@ -3,6 +3,7 @@ import { useYjs } from "~/lib/use-yjs";
import { useRouting } from "~/lib/use-routing";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
const PlannerMap = lazy(() =>
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
@ -83,6 +84,7 @@ export function SessionView({ sessionId }: { sessionId: string }) {
</Suspense>
</aside>
</div>
<YjsDebugPanel yjs={yjs} />
</>
);
}

View file

@ -30,9 +30,9 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
useEffect(() => {
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints));
yjs.waypoints.observe(update);
yjs.waypoints.observeDeep(update);
update();
return () => yjs.waypoints.unobserve(update);
return () => yjs.waypoints.unobserveDeep(update);
}, [yjs.waypoints]);
const deleteWaypoint = useCallback(

View file

@ -0,0 +1,255 @@
import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import * as awarenessProtocol from "y-protocols/awareness";
import type { YjsState } from "~/lib/use-yjs";
// --- localStorage helpers ---
function loadBool(key: string, fallback: boolean): boolean {
try { return localStorage.getItem(key) === "1"; } catch { return fallback; }
}
function saveBool(key: string, value: boolean) {
try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* localStorage unavailable */ }
}
// --- Debug state ---
interface DebugState {
waypoints: Array<Record<string, unknown>>;
routeData: Record<string, unknown>;
awareness: Array<{ clientId: number; state: Record<string, unknown> }>;
docSize: number;
}
function serializeYMap(yMap: Y.Map<unknown>): Record<string, unknown> {
const obj: Record<string, unknown> = {};
yMap.forEach((value, key) => {
if (value instanceof Y.Map) {
obj[key] = serializeYMap(value);
} else if (value instanceof Y.Array) {
obj[key] = `Y.Array(${value.length})`;
} else if (typeof value === "string" && value.length > 200) {
obj[key] = value.slice(0, 200) + `... (${value.length} chars)`;
} else {
obj[key] = value;
}
});
return obj;
}
function getDebugState(yjs: YjsState): DebugState {
const waypoints = yjs.waypoints.toArray().map((yMap) => serializeYMap(yMap));
const routeData: Record<string, unknown> = {};
yjs.routeData.forEach((value, key) => {
if (typeof value === "string" && value.length > 100) {
routeData[key] = `string(${value.length} chars)`;
} else {
routeData[key] = value;
}
});
const awareness: DebugState["awareness"] = [];
yjs.awareness.getStates().forEach((state, clientId) => {
awareness.push({ clientId, state: state as Record<string, unknown> });
});
const docSize = Y.encodeStateAsUpdate(yjs.doc).byteLength;
return { waypoints, routeData, awareness, docSize };
}
// --- Styles ---
const btnClass =
"rounded bg-gray-700 px-2 py-0.5 text-[10px] hover:bg-gray-600 active:bg-gray-500 transition-colors";
const dangerBtnClass =
"rounded bg-red-900 px-2 py-0.5 text-[10px] text-red-200 hover:bg-red-800 active:bg-red-700 transition-colors";
// --- Component ---
/**
* Yjs debug panel with:
* - Visibility: shown in dev by default, toggle with Shift+Cmd+Option+T anywhere
* - Expanded state: persisted in localStorage
* - Actions: reset session, refetch route, become host, kick users
* - State inspector: awareness, waypoints, route data, doc stats
*/
export function YjsDebugPanel({ yjs }: { yjs: YjsState }) {
const [visible, setVisible] = useState(() => loadBool("trails:debug", import.meta.env.DEV));
const [expanded, setExpanded] = useState(() => loadBool("trails:debug:expanded", false));
const [state, setState] = useState<DebugState | null>(null);
// Hotkey: Shift+Cmd+Option+T
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.shiftKey && e.metaKey && e.altKey && e.code === "KeyT") {
e.preventDefault();
setVisible((v) => {
const next = !v;
saveBool("trails:debug", next);
return next;
});
}
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, []);
// Sync Yjs state
useEffect(() => {
if (!visible) return;
const update = () => setState(getDebugState(yjs));
yjs.waypoints.observe(update);
yjs.routeData.observe(update);
yjs.awareness.on("change", update);
update();
const interval = setInterval(update, 2000);
return () => {
yjs.waypoints.unobserve(update);
yjs.routeData.unobserve(update);
yjs.awareness.off("change", update);
clearInterval(interval);
};
}, [yjs, visible]);
// Actions
const resetSession = useCallback(() => {
yjs.doc.transact(() => {
while (yjs.waypoints.length > 0) {
yjs.waypoints.delete(0, 1);
}
yjs.routeData.delete("geojson");
yjs.routeData.delete("profile");
});
}, [yjs]);
const refetchRoute = useCallback(async () => {
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
}));
if (waypoints.length < 2) return;
const profile = (yjs.routeData.get("profile") as string) ?? "trekking";
const response = await fetch("/api/route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ waypoints, profile }),
});
if (response.ok) {
const geojson = await response.json();
yjs.routeData.set("geojson", JSON.stringify(geojson));
}
}, [yjs]);
const kickUser = useCallback(
(clientId: number) => {
if (clientId === yjs.awareness.clientID) return;
awarenessProtocol.removeAwarenessStates(yjs.awareness, [clientId], "kicked");
},
[yjs],
);
const toggleExpanded = useCallback(() => {
setExpanded((v) => {
const next = !v;
saveBool("trails:debug:expanded", next);
return next;
});
}, []);
// Render
if (!visible || !state) return null;
return (
<div className="fixed bottom-0 right-0 z-[2000] max-h-[50vh] w-96 overflow-auto rounded-tl-lg border-l border-t border-gray-300 bg-gray-900 text-xs text-gray-100 shadow-lg">
<button
onClick={toggleExpanded}
className="sticky top-0 flex w-full items-center justify-between bg-gray-800 px-3 py-1.5 font-mono text-xs hover:bg-gray-700"
>
<span>Yjs Debug</span>
<span className="text-gray-400">
{state.waypoints.length} wps · {state.awareness.length} users · {(state.docSize / 1024).toFixed(1)}KB
</span>
</button>
{expanded && (
<div className="space-y-3 p-3 font-mono">
<Section title="Actions">
<div className="flex flex-wrap gap-1.5">
<button onClick={refetchRoute} className={btnClass}>
Refetch route
</button>
<button onClick={resetSession} className={dangerBtnClass}>
Reset session
</button>
</div>
</Section>
<Section title="Awareness">
{state.awareness.map((a) => (
<div key={a.clientId} className="mb-2">
<div className="flex items-center gap-2">
<span className="text-yellow-400">client:{a.clientId}</span>
{a.clientId === yjs.awareness.clientID && (
<span className="text-green-400">(you)</span>
)}
{Object.keys(a.state).length === 0 && (
<span className="text-gray-500">(server)</span>
)}
{a.clientId !== yjs.awareness.clientID && Object.keys(a.state).length > 0 && (
<button
onClick={() => kickUser(a.clientId)}
className={dangerBtnClass}
>
kick
</button>
)}
</div>
<pre className="ml-2 text-gray-400">{JSON.stringify(a.state, null, 1)}</pre>
</div>
))}
</Section>
<Section title={`Waypoints (${state.waypoints.length})`}>
{state.waypoints.map((wp, i) => (
<div key={i} className="text-gray-300">
[{i}] {JSON.stringify(wp)}
</div>
))}
{state.waypoints.length === 0 && <span className="text-gray-500">empty</span>}
</Section>
<Section title="Route Data">
<pre className="text-gray-300">{JSON.stringify(state.routeData, null, 1)}</pre>
</Section>
<Section title="Doc Stats">
<div className="text-gray-300">
Size: {(state.docSize / 1024).toFixed(1)} KB
</div>
</Section>
</div>
)}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1 text-blue-400">{title}</div>
<div className="ml-1">{children}</div>
</div>
);
}

View file

@ -0,0 +1,167 @@
import { describe, it, expect } from "vitest";
// Test the mergeGeoJsonSegments logic extracted from brouter.ts
interface GeoJsonFeature {
type: string;
properties: Record<string, unknown>;
geometry: { type: string; coordinates: number[][] };
}
interface GeoJsonCollection {
type: string;
features: GeoJsonFeature[];
}
function mergeGeoJsonSegments(segments: GeoJsonCollection[]): GeoJsonCollection {
const allCoords: number[][] = [];
let totalLength = 0;
let totalAscend = 0;
let totalTime = 0;
for (let i = 0; i < segments.length; i++) {
const feature = segments[i]!.features?.[0];
if (!feature) continue;
const coords = feature.geometry.coordinates;
const startIdx = i === 0 ? 0 : 1;
for (let j = startIdx; j < coords.length; j++) {
allCoords.push(coords[j]!);
}
const props = feature.properties;
totalLength += parseInt(String(props["track-length"] ?? "0"));
totalAscend += parseInt(String(props["filtered ascend"] ?? "0"));
totalTime += parseInt(String(props["total-time"] ?? "0"));
}
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
"track-length": String(totalLength),
"filtered ascend": String(totalAscend),
"total-time": String(totalTime),
creator: "trails.cool (BRouter segments)",
},
geometry: {
type: "LineString",
coordinates: allCoords,
},
},
],
};
}
function makeSegment(coords: number[][], length: number, ascend: number): GeoJsonCollection {
return {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: {
"track-length": String(length),
"filtered ascend": String(ascend),
"total-time": "100",
},
geometry: { type: "LineString", coordinates: coords },
}],
};
}
describe("mergeGeoJsonSegments", () => {
it("merges two segments", () => {
const seg1 = makeSegment([[13.0, 52.0], [13.1, 52.1], [13.2, 52.2]], 1000, 10);
const seg2 = makeSegment([[13.2, 52.2], [13.3, 52.3], [13.4, 52.4]], 1500, 20);
const result = mergeGeoJsonSegments([seg1, seg2]);
const coords = result.features[0]!.geometry.coordinates;
// Should have 5 points (first segment 3 + second segment 2, skipping duplicate)
expect(coords).toHaveLength(5);
expect(coords[0]).toEqual([13.0, 52.0]);
expect(coords[2]).toEqual([13.2, 52.2]); // shared point
expect(coords[4]).toEqual([13.4, 52.4]);
});
it("skips duplicate point at segment boundaries", () => {
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
const seg2 = makeSegment([[2, 2], [3, 3]], 100, 0);
const seg3 = makeSegment([[3, 3], [4, 4]], 100, 0);
const result = mergeGeoJsonSegments([seg1, seg2, seg3]);
const coords = result.features[0]!.geometry.coordinates;
expect(coords).toHaveLength(4);
expect(coords).toEqual([[1, 1], [2, 2], [3, 3], [4, 4]]);
});
it("accumulates stats across segments", () => {
const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50);
const seg2 = makeSegment([[2, 2], [3, 3]], 2000, 30);
const result = mergeGeoJsonSegments([seg1, seg2]);
const props = result.features[0]!.properties;
expect(props["track-length"]).toBe("3000");
expect(props["filtered ascend"]).toBe("80");
expect(props["total-time"]).toBe("200");
});
it("handles single segment", () => {
const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10);
const result = mergeGeoJsonSegments([seg]);
expect(result.features[0]!.geometry.coordinates).toHaveLength(3);
expect(result.features[0]!.properties["track-length"]).toBe("500");
});
it("handles empty segment gracefully", () => {
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] };
const result = mergeGeoJsonSegments([seg1, empty]);
expect(result.features[0]!.geometry.coordinates).toHaveLength(2);
});
it("preserves elevation data in coordinates", () => {
const seg1 = makeSegment([[13.0, 52.0, 100], [13.1, 52.1, 150]], 500, 50);
const seg2 = makeSegment([[13.1, 52.1, 150], [13.2, 52.2, 200]], 500, 50);
const result = mergeGeoJsonSegments([seg1, seg2]);
const coords = result.features[0]!.geometry.coordinates;
expect(coords).toHaveLength(3);
expect(coords[0]).toEqual([13.0, 52.0, 100]);
expect(coords[1]).toEqual([13.1, 52.1, 150]);
expect(coords[2]).toEqual([13.2, 52.2, 200]);
});
});
describe("waypoint to BRouter segments", () => {
it("splits N waypoints into N-1 pairs", () => {
const waypoints = [
{ lat: 52.0, lon: 13.0 },
{ lat: 52.1, lon: 13.1 },
{ lat: 52.2, lon: 13.2 },
{ lat: 52.3, lon: 13.3 },
];
const pairs: Array<[typeof waypoints[0], typeof waypoints[0]]> = [];
for (let i = 0; i < waypoints.length - 1; i++) {
pairs.push([waypoints[i]!, waypoints[i + 1]!]);
}
expect(pairs).toHaveLength(3);
expect(pairs[0]).toEqual([waypoints[0], waypoints[1]]);
expect(pairs[1]).toEqual([waypoints[1], waypoints[2]]);
expect(pairs[2]).toEqual([waypoints[2], waypoints[3]]);
});
it("formats lon,lat correctly for BRouter", () => {
const wp = { lat: 52.5277, lon: 13.4033 };
const lonlat = `${wp.lon},${wp.lat}`;
expect(lonlat).toBe("13.4033,52.5277");
});
});

View file

@ -7,29 +7,101 @@ export interface RouteRequest {
format?: string;
}
/**
* Compute a route segment-by-segment between consecutive waypoints.
* This matches bikerouter.de's behavior and guarantees the route
* passes through every waypoint.
*/
export async function computeRoute(request: RouteRequest): Promise<unknown> {
if (request.waypoints.length < 2) {
throw new Error("At least 2 waypoints are required");
}
const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|");
const profile = request.profile ?? "trekking";
const format = request.format ?? "geojson";
const params = new URLSearchParams({
lonlats,
profile: request.profile ?? "trekking",
alternativeidx: String(request.alternativeIdx ?? 0),
format: request.format ?? "geojson",
});
// Route each segment independently
const segments = await Promise.all(
request.waypoints.slice(0, -1).map((wp, i) => {
const next = request.waypoints[i + 1]!;
const lonlats = `${wp.lon},${wp.lat}|${next.lon},${next.lat}`;
const params = new URLSearchParams({
lonlats,
profile,
alternativeidx: "0",
format,
});
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
}),
);
const url = `${BROUTER_URL}/brouter?${params}`;
// Merge GeoJSON segments into a single FeatureCollection
return mergeGeoJsonSegments(segments);
}
async function fetchSegment(url: string): Promise<Record<string, unknown>> {
const response = await fetch(url);
if (!response.ok) {
const body = await response.text();
throw new Error(`BRouter error (${response.status}): ${body}`);
}
return response.json() as Promise<Record<string, unknown>>;
}
return response.json();
interface GeoJsonFeature {
type: string;
properties: Record<string, unknown>;
geometry: { type: string; coordinates: number[][] };
}
interface GeoJsonCollection {
type: string;
features: GeoJsonFeature[];
}
function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonCollection {
const allCoords: number[][] = [];
let totalLength = 0;
let totalAscend = 0;
let totalTime = 0;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i] as unknown as GeoJsonCollection;
const feature = segment.features?.[0];
if (!feature) continue;
const coords = feature.geometry.coordinates;
// Skip first point of subsequent segments to avoid duplicates
const startIdx = i === 0 ? 0 : 1;
for (let j = startIdx; j < coords.length; j++) {
allCoords.push(coords[j]!);
}
// Accumulate stats
const props = feature.properties;
totalLength += parseInt(String(props["track-length"] ?? "0"));
totalAscend += parseInt(String(props["filtered ascend"] ?? "0"));
totalTime += parseInt(String(props["total-time"] ?? "0"));
}
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
"track-length": String(totalLength),
"filtered ascend": String(totalAscend),
"total-time": String(totalTime),
creator: "trails.cool (BRouter segments)",
},
geometry: {
type: "LineString",
coordinates: allCoords,
},
},
],
};
}
export function getBRouterUrl(): string {

View file

@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { electHost } from "./host-election";
function makeStates(
entries: Array<{ id: number; user?: boolean; role?: string }>,
): Map<number, Record<string, unknown>> {
const map = new Map<number, Record<string, unknown>>();
for (const e of entries) {
const state: Record<string, unknown> = {};
if (e.user) state.user = { color: "#000", name: "Test" };
if (e.role) state.role = e.role;
map.set(e.id, state);
}
return map;
}
describe("electHost", () => {
it("elects the only real client as host", () => {
const states = makeStates([
{ id: 100, user: true },
]);
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
});
it("elects lowest ID among two real clients", () => {
const states = makeStates([
{ id: 200, user: true },
{ id: 100, user: true },
]);
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" });
});
it("ignores server docs (no user state)", () => {
const states = makeStates([
{ id: 50 }, // server doc — lowest ID but no user
{ id: 200, user: true },
{ id: 100, user: true },
]);
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" });
});
it("always assigns a role (never undefined)", () => {
const states = makeStates([
{ id: 300, user: true },
{ id: 100, user: true },
{ id: 200, user: true },
]);
const result = electHost(states, 200);
expect(result.role).toBeDefined();
expect(result.role).toBe("participant");
});
it("handles single client with server doc", () => {
const states = makeStates([
{ id: 999 }, // server
{ id: 500, user: true },
]);
expect(electHost(states, 500)).toEqual({ isHost: true, role: "host" });
});
it("handles no real clients (defensive)", () => {
const states = makeStates([
{ id: 10 }, // server only
]);
// localId not in states — still returns host as fallback
expect(electHost(states, 42)).toEqual({ isHost: true, role: "host" });
});
it("ignores existing role claims — always uses lowest ID", () => {
const states = makeStates([
{ id: 200, user: true, role: "host" },
{ id: 100, user: true, role: "participant" },
]);
// ID 100 should be host regardless of current role claims
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" });
});
it("works with many clients", () => {
const states = makeStates([
{ id: 999, user: true },
{ id: 500, user: true },
{ id: 42 }, // server
{ id: 200, user: true },
{ id: 100, user: true },
{ id: 800, user: true },
]);
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
expect(electHost(states, 500)).toEqual({ isHost: false, role: "participant" });
expect(electHost(states, 999)).toEqual({ isHost: false, role: "participant" });
});
});

View file

@ -0,0 +1,26 @@
/**
* Determines which client should be the routing host.
*
* Simple rule: the real client (has user state) with the lowest
* client ID is always the host. Deterministic, no race conditions.
*/
export function electHost(
states: Map<number, Record<string, unknown>>,
localId: number,
): { isHost: boolean; role: "host" | "participant" } {
let lowestRealId = Infinity;
states.forEach((state, clientId) => {
if (state.user) {
if (clientId < lowestRealId) lowestRealId = clientId;
}
});
// No real clients found (shouldn't happen, but defensive)
if (lowestRealId === Infinity) {
return { isHost: true, role: "host" };
}
const isHost = localId === lowestRealId;
return { isHost, role: isHost ? "host" : "participant" };
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import type { YjsState } from "./use-yjs";
import { electHost } from "./host-election";
interface RouteStats {
distance?: number;
@ -31,17 +32,11 @@ export function useRouting(yjs: YjsState | null) {
if (!yjs) return;
const checkHost = () => {
const states = yjs.awareness.getStates();
const states = yjs.awareness.getStates() as Map<number, Record<string, unknown>>;
const localId = yjs.awareness.clientID;
let lowestId = Infinity;
states.forEach((_state, clientId) => {
if (clientId < lowestId) lowestId = clientId;
});
const amHost = localId === lowestId;
const { isHost: amHost, role } = electHost(states, localId);
setIsHost(amHost);
yjs.awareness.setLocalStateField("role", amHost ? "host" : "participant");
yjs.awareness.setLocalStateField("role", role);
};
yjs.awareness.on("change", checkHost);

View file

@ -16,6 +16,17 @@ function randomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function getOrCreateUserIdentity(): { color: string; name: string } {
try {
const stored = localStorage.getItem("trails:user");
if (stored) return JSON.parse(stored);
} catch { /* localStorage unavailable */ }
const identity = { color: randomItem(COLORS), name: randomItem(NAMES) };
try { localStorage.setItem("trails:user", JSON.stringify(identity)); } catch { /* localStorage unavailable */ }
return identity;
}
export interface YjsState {
doc: Y.Doc;
provider: WebsocketProvider;
@ -41,8 +52,8 @@ export function useYjs(sessionId: string): YjsState | null {
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
const routeData = doc.getMap("routeData");
const color = randomItem(COLORS);
const name = randomItem(NAMES);
// Use persistent identity
const { color, name } = getOrCreateUserIdentity();
provider.awareness.setLocalStateField("user", { color, name });
const updateState = (connected: boolean) => {
@ -60,7 +71,6 @@ export function useYjs(sessionId: string): YjsState | null {
updateState(status === "connected");
});
// Set initial state (even before connection)
updateState(false);
return () => {

View file

@ -16,7 +16,8 @@ const messageAwareness = 1;
export function yjsDevPlugin(): Plugin {
const docs = new Map<string, Y.Doc>();
const awarenessMap = new Map<string, awarenessProtocol.Awareness>();
const conns = new Map<WebSocket, { docName: string; awarenessIds: Set<number> }>();
// Map each WebSocket to its doc name and the awareness client IDs it owns
const conns = new Map<WebSocket, { docName: string; clientIds: Set<number> }>();
function getDoc(docName: string): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } {
let doc = docs.get(docName);
@ -35,7 +36,7 @@ export function yjsDevPlugin(): Plugin {
const message = encoding.toUint8Array(encoder);
for (const [ws, meta] of conns) {
if (meta.docName === docName) {
if (meta.docName === docName && ws.readyState === ws.OPEN) {
ws.send(message);
}
}
@ -63,6 +64,20 @@ export function yjsDevPlugin(): Plugin {
case messageAwareness: {
const update = decoding.readVarUint8Array(decoder);
awarenessProtocol.applyAwarenessUpdate(awareness, update, ws);
// Track which client IDs this WebSocket is responsible for
// by checking what was added/updated in this awareness update
const meta = conns.get(ws);
if (meta) {
awareness.getStates().forEach((_state, clientId) => {
// The awareness meta stores which "origin" (ws) last updated each client
const awarenessClientMeta = awareness.meta.get(clientId);
if (awarenessClientMeta) {
// If this client ID was just updated from this connection, track it
meta.clientIds.add(clientId);
}
});
}
break;
}
}
@ -81,7 +96,7 @@ export function yjsDevPlugin(): Plugin {
wss.handleUpgrade(request, socket, head, (ws) => {
const { doc, awareness } = getDoc(docName);
conns.set(ws, { docName, awarenessIds: new Set() });
conns.set(ws, { docName, clientIds: new Set() });
// Send sync step 1
const encoder = encoding.createEncoder();
@ -89,7 +104,7 @@ export function yjsDevPlugin(): Plugin {
syncProtocol.writeSyncStep1(encoder, doc);
ws.send(encoding.toUint8Array(encoder));
// Send awareness states
// Send current awareness states
const awarenessStates = awareness.getStates();
if (awarenessStates.size > 0) {
const awarenessEncoder = encoding.createEncoder();
@ -107,8 +122,13 @@ export function yjsDevPlugin(): Plugin {
ws.on("close", () => {
const meta = conns.get(ws);
if (meta) {
awarenessProtocol.removeAwarenessStates(awareness, Array.from(meta.awarenessIds), null);
if (meta && meta.clientIds.size > 0) {
// Immediately remove awareness states for all clients on this connection
awarenessProtocol.removeAwarenessStates(
awareness,
Array.from(meta.clientIds),
null,
);
}
conns.delete(ws);
});