Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ jobs:
pull-requests: write
steps:
- name: 'Auto-assign issue'
uses: pozil/auto-assign-issue@v1
uses: pozil/auto-assign-issue@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
assignees: dezren39
numOfAssignee: 1
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2024-05-18 - ProseMirror Decoration Node Recycling
**Learning:** ProseMirror reuses DOM nodes for decorations if the `key` matches and it doesn't use the DOM layout, however, if `key` changes on every keystroke, ProseMirror completely recreates DOM node trees causing severe layout thrashing. Also, if a widget decoration creates its DOM synchronously instead of passing a factory function `() => HTMLElement`, the factory evaluation happens immediately anyway causing layout calculations before Prosemirror renders.
**Action:** When implementing ProseMirror decorations, always use the factory function pattern `() => HTMLElement` for `Decoration.widget` construction and derive the `key` string from underlying data properties (e.g., ID, position, opacity, connectionState) to enable DOM node recycling and avoid performance bottlenecks from layout thrashing.
4 changes: 2 additions & 2 deletions pkgs/id/flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,9 @@
nix-fmt-check = pkgs.stdenv.mkDerivation {
name = "id-nix-fmt-check";
src = ./.;
nativeBuildInputs = [ pkgs.nixfmt ];
nativeBuildInputs = [ pkgs.nixfmt-rfc-style ];
buildPhase = ''
find . -name '*.nix' -not -path './web/bun.nix' -not -path './e2e/bun.nix' | xargs nixfmt --check
find . -name '*.nix' -not -path './web/bun.nix' -not -path './e2e/bun.nix' | xargs nixfmt-rfc-style --check
'';
installPhase = ''
mkdir -p $out
Expand Down
2 changes: 1 addition & 1 deletion pkgs/id/nix-common.nix
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ let
# Formatters and linters (keep in sync with treefmt.toml)
biome
prettier # nodepackages remoed 2026-04-03
nixfmt
nixfmt-rfc-style
statix
shfmt
shellcheck
Expand Down
166 changes: 84 additions & 82 deletions pkgs/id/nix/tests/playwright-e2e-test.nix
Original file line number Diff line number Diff line change
Expand Up @@ -22,92 +22,94 @@
name = "id-playwright-e2e";

# ── Server nodes: each runs two isolated id service instances ────────────
nodes.chromium_server =
{ ... }:
{
imports = [ ../id-module.nix ];

services.id = {
package = idPackage;
instances.primary = {
enable = true;
web = true;
port = 4173;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
};
instances.secondary = {
enable = true;
web = true;
port = 4175;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
nodes = {
chromium_server =
{ ... }:
{
imports = [ ../id-module.nix ];

services.id = {
package = idPackage;
instances.primary = {
enable = true;
web = true;
port = 4173;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
};
instances.secondary = {
enable = true;
web = true;
port = 4175;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
};
};
};
};

nodes.firefox_server =
{ ... }:
{
imports = [ ../id-module.nix ];

services.id = {
package = idPackage;
instances.primary = {
enable = true;
web = true;
port = 4174;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
};
instances.secondary = {
enable = true;
web = true;
port = 4176;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;

firefox_server =
{ ... }:
{
imports = [ ../id-module.nix ];

services.id = {
package = idPackage;
instances.primary = {
enable = true;
web = true;
port = 4174;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
};
instances.secondary = {
enable = true;
web = true;
port = 4176;
ephemeral = true;
noRelay = true;
noGossip = true;
noMdns = true;
openFirewall = true;
};
};
};
};

# ── Client nodes: Playwright + browser, tests run here ──────────────────
nodes.chromium_client =
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.nodejs
pkgs.curl
];

# Chromium needs resources for rendering
virtualisation.memorySize = 4096;
virtualisation.cores = 2;
};

nodes.firefox_client =
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.nodejs
pkgs.curl
];

# Firefox needs resources for rendering
virtualisation.memorySize = 4096;
virtualisation.cores = 2;
};

# ── Client nodes: Playwright + browser, tests run here ──────────────────
chromium_client =
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.nodejs
pkgs.curl
];

# Chromium needs resources for rendering
virtualisation.memorySize = 4096;
virtualisation.cores = 2;
};

firefox_client =
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.nodejs
pkgs.curl
];

# Firefox needs resources for rendering
virtualisation.memorySize = 4096;
virtualisation.cores = 2;
};
};

globalTimeout = 1200; # 20 minutes — doubled: 4 Playwright runs instead of 2

Expand Down
140 changes: 75 additions & 65 deletions pkgs/id/web/src/cursors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@ const RECONNECT_CLEANUP_DELAY_MS = 3_000; // Wait 3s after Init before cleanup (
const HOVER_RESTORE_DELAY_MS = 1_000; // 1s delay before restoring after hover
const PENDING_REMOVAL_MS = 5_000; // Show fading cursor for 5s after disconnect

// Global counter to force widget recreation on every decoration call
let decorationGeneration = 0;

export interface CursorInfo {
clientID: string | number;
head: number;
Expand Down Expand Up @@ -457,10 +454,6 @@ function createCursorDecorations(
const now = Date.now();
const localHead = state.selection.head;

// Increment generation to force widget recreation
decorationGeneration++;
const gen = decorationGeneration;

// Normalize myClientID to number for comparison (handles string/number mismatch)
const myClientIDNum = myClientID !== null ? Number(myClientID) : null;

Expand Down Expand Up @@ -647,16 +640,25 @@ function createCursorDecorations(

// Create own cursor decoration if present in cluster
if (clusterHasOwnCursor && ownCursor) {
const ownCursorLine = document.createElement("span");
ownCursorLine.className = "collab-cursor collab-cursor-own";
ownCursorLine.style.borderColor = ownCursor.color;
ownCursorLine.setAttribute("data-client-id", String(ownCursor.clientID));

decorations.push(
Decoration.widget(ownCursor.head, ownCursorLine, {
side: 1,
key: `cursor-own-${ownCursor.clientID}-g${gen}`,
}),
Decoration.widget(
ownCursor.head,
() => {
const ownCursorLine = document.createElement("span");
ownCursorLine.className = "collab-cursor collab-cursor-own";
// We need to store these references in scope because TypeScript
// can't verify that ownCursor isn't mutated
const _color = ownCursor!.color;
const _clientId = ownCursor!.clientID;
ownCursorLine.style.borderColor = _color;
ownCursorLine.setAttribute("data-client-id", String(_clientId));
return ownCursorLine;
},
{
side: 1,
key: `cursor-own-${ownCursor.clientID}-${ownCursor.head}`,
},
),
);
}

Expand Down Expand Up @@ -684,66 +686,74 @@ function createCursorDecorations(
.sort()
.join("-");

// Generate keys based on cursor state
const clusterStateKey = groupsForBar
.flatMap((g) => g.cursors.map((c) => `${c.opacity}`))
.join("-");

// Create cursor lines for each position group (no attached labels)
groupsForBar.forEach((group) => {
const firstCursor = group.cursors[0];
const allClientIDs = group.cursors.map((c) => c.clientID);
const cursorLine = createCursorLine(firstCursor, true, connectionState, allClientIDs);
cursorLine.style.borderColor = group.mostRecentColor;

// For merged cursor lines with bars, use full opacity on the line
// (the bar segments handle their own individual opacities)
if (group === leftmostGroup) {
cursorLine.style.opacity = "1";
cursorLine.style.setProperty("--base-opacity", "1");
cursorLine.style.animation = "none"; // No strobe on bar container
}

// Track the leftmost cursor line for bar attachment
if (group === leftmostGroup) {
leftmostCursorLine = cursorLine;
}
decorations.push(
Decoration.widget(
group.position,
() => {
const firstCursor = group.cursors[0];
const allClientIDs = group.cursors.map((c) => c.clientID);
const cursorLine = createCursorLine(firstCursor, true, connectionState, allClientIDs);
cursorLine.style.borderColor = group.mostRecentColor;

// For merged cursor lines with bars, use full opacity on the line
// (the bar segments handle their own individual opacities)
if (group === leftmostGroup) {
cursorLine.style.opacity = "1";
cursorLine.style.setProperty("--base-opacity", "1");
cursorLine.style.animation = "none"; // No strobe on bar container

// Create merged tooltip bar and attach to leftmost cursor line
// (appending to cursor line ensures proper positioning via position: relative)
const mergedBar = createMergedBar(groupsForBar, handleMouseEnter, handleMouseLeave, connectionState);
cursorLine.appendChild(mergedBar);
}

// Register all cursors in this group for strobe tracking
if (viewState) {
group.cursors.forEach((cursor) => {
viewState.strobeInfos.set(cursor.clientID, {
element: cursorLine,
baseOpacity: cursor.opacity,
paused: false,
});
});
}
// Register all cursors in this group for strobe tracking
if (viewState) {
group.cursors.forEach((cursor) => {
viewState.strobeInfos.set(cursor.clientID, {
element: cursorLine,
baseOpacity: cursor.opacity,
paused: false,
});
});
}

// Use cluster-wide key + generation to force recreation on every update
decorations.push(
Decoration.widget(group.position, cursorLine, {
side: 1,
key: `cursor-cluster-${group.position}-${clusterKey}-g${gen}`,
}),
return cursorLine;
},
{
side: 1,
// Use cluster-wide key + positions + states to force recreation on relevant updates
key: `cursor-cluster-${group.position}-${clusterKey}-${clusterStateKey}-${connectionState}`,
},
),
);
});

// Create merged tooltip bar and attach to leftmost cursor line
// (appending to cursor line ensures proper positioning via position: relative)
const mergedBar = createMergedBar(groupsForBar, handleMouseEnter, handleMouseLeave, connectionState);

if (leftmostCursorLine) {
(leftmostCursorLine as HTMLElement).appendChild(mergedBar);
}
} else {
// Single remote cursor, no overlap - simple standalone cursor
const group = groupsForBar[0];
const cursor = group.cursors[0];
const cursorWidget = viewState
? createStandaloneCursor(cursor, connectionState, viewState, handleMouseEnter, handleMouseLeave)
: createCursorLine(cursor, false, connectionState);

decorations.push(
Decoration.widget(cursor.head, cursorWidget, {
side: 1,
key: `cursor-${cursor.clientID}-g${gen}`,
}),
Decoration.widget(
cursor.head,
() => {
return viewState
? createStandaloneCursor(cursor, connectionState, viewState, handleMouseEnter, handleMouseLeave)
: createCursorLine(cursor, false, connectionState);
},
{
side: 1,
key: `cursor-${cursor.clientID}-${cursor.head}-${cursor.opacity}-${connectionState}`,
},
),
);
}
});
Expand All @@ -765,7 +775,7 @@ function createCursorDecorations(
style: `background-color: ${cursor.color}${selectionOpacity};`,
},
{
key: `selection-${cursor.clientID}-g${gen}`,
key: `selection-${cursor.clientID}-${from}-${to}-${selectionOpacity}`,
},
),
);
Expand Down
Loading