Skip to content
Open
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
48 changes: 27 additions & 21 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
# vscode
.vscode

# Intellij
*.iml
.idea

# npm
node_modules

*.js

# Exclude sourcemaps
*.map

# obsidian
data.json

# Exclude macOS Finder (System Explorer) View States
.DS_Store
vaults/
# vscode
.vscode

# Intellij
*.iml
.idea

# npm
node_modules

*.js

# Exclude sourcemaps
*.map

# obsidian
data.json

# Exclude macOS Finder (System Explorer) View States
.DS_Store
vaults/

# Development
dev.sh

# Claude Code
.claude/
257 changes: 257 additions & 0 deletions __tests__/UserDisconnectedIntent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"use strict";

/**
* Tests for the userDisconnectedIntent flag pattern.
*
* This flag tracks whether a user has EXPLICITLY disconnected a document
* via the UI toggle, as opposed to the document being disconnected due to:
* - View lifecycle (release() when navigating away)
* - Network issues
* - Other system-initiated disconnections
*
* The flag should:
* - Default to false (documents should connect by default)
* - Only be set to true via explicit user action (toggleConnection)
* - NOT be affected by disconnect() calls
* - Be used by LiveViews.getViews() to determine shouldConnect for new LiveView instances
*
* Bug context: When a user disconnects a note and navigates away/back,
* the note was auto-reconnecting because getViews() created new LiveView
* instances with shouldConnect=true by default, ignoring user intent.
*
* This test file tests the FLAG BEHAVIOR PATTERN in isolation, without
* requiring full Obsidian dependencies. The actual Document/Canvas classes
* follow this same pattern.
*
* For E2E verification, use the live-debug skill:
* python .claude/scripts/obsidian_debug.py --vault <name> relay file-status "test.md"
*/

/**
* Minimal class that mimics the userDisconnectedIntent pattern
* implemented in Document and Canvas.
*/
class MockDocumentWithUserIntent {
/**
* Tracks whether the user has explicitly disconnected this document.
* - Default: false (connect by default)
* - Set to true: only via explicit user action (toggleConnection)
* - NOT modified by: disconnect(), release(), network issues
*/
userDisconnectedIntent: boolean = false;

// Simulates provider's shouldConnect (affected by disconnect())
private _providerShouldConnect: boolean = true;

/**
* Simulates the disconnect() method from HasProvider.
* This sets provider.shouldConnect = false but should NOT
* affect userDisconnectedIntent.
*/
disconnect(): void {
this._providerShouldConnect = false;
// NOTE: userDisconnectedIntent is NOT modified here
}

/**
* Simulates the connect() method from HasProvider.
*/
connect(): void {
this._providerShouldConnect = true;
}

/**
* Simulates intent getter (derived from provider.shouldConnect).
*/
get intent(): "connected" | "disconnected" {
return this._providerShouldConnect ? "connected" : "disconnected";
}
}

/**
* Simulates LiveView behavior with the userDisconnectedIntent fix.
*/
class MockLiveView {
shouldConnect: boolean;
document: MockDocumentWithUserIntent;

constructor(document: MockDocumentWithUserIntent, shouldConnect = true) {
this.document = document;
this.shouldConnect = shouldConnect;
}

/**
* Simulates the toggleConnection() method with the fix applied.
*/
toggleConnection(): void {
this.shouldConnect = !this.shouldConnect;
// THE FIX: Track explicit user disconnect intent
this.document.userDisconnectedIntent = !this.shouldConnect;
if (this.shouldConnect) {
this.document.connect();
} else {
this.document.disconnect();
}
}

/**
* Simulates release() when navigating away.
*/
release(): void {
this.document.disconnect();
// NOTE: userDisconnectedIntent is NOT modified here
}
}

/**
* Simulates getViews() creating a new LiveView with the fix applied.
*/
function createLiveViewFromDocument(
doc: MockDocumentWithUserIntent,
): MockLiveView {
// THE FIX: Use userDisconnectedIntent to determine shouldConnect
const shouldConnect = !doc.userDisconnectedIntent;
return new MockLiveView(doc, shouldConnect);
}

describe("userDisconnectedIntent flag pattern", () => {
describe("Document flag behavior", () => {
let doc: MockDocumentWithUserIntent;

beforeEach(() => {
doc = new MockDocumentWithUserIntent();
});

it("should default userDisconnectedIntent to false", () => {
expect(doc.userDisconnectedIntent).toBe(false);
});

it("should preserve userDisconnectedIntent=true after disconnect()", () => {
doc.userDisconnectedIntent = true;
doc.disconnect();
expect(doc.userDisconnectedIntent).toBe(true);
});

it("should preserve userDisconnectedIntent=false after disconnect()", () => {
expect(doc.userDisconnectedIntent).toBe(false);
doc.disconnect();
expect(doc.userDisconnectedIntent).toBe(false);
});
});

describe("LiveView toggleConnection behavior", () => {
let doc: MockDocumentWithUserIntent;
let view: MockLiveView;

beforeEach(() => {
doc = new MockDocumentWithUserIntent();
view = new MockLiveView(doc);
});

it("should set userDisconnectedIntent=true when user disconnects", () => {
expect(doc.userDisconnectedIntent).toBe(false);
view.toggleConnection(); // User clicks to disconnect
expect(doc.userDisconnectedIntent).toBe(true);
expect(view.shouldConnect).toBe(false);
});

it("should set userDisconnectedIntent=false when user reconnects", () => {
view.toggleConnection(); // Disconnect
expect(doc.userDisconnectedIntent).toBe(true);

view.toggleConnection(); // Reconnect
expect(doc.userDisconnectedIntent).toBe(false);
expect(view.shouldConnect).toBe(true);
});
});

describe("Navigation scenario (the bug fix)", () => {
it("should preserve disconnect intent across navigation", () => {
// Setup: Document exists, user opens it
const doc = new MockDocumentWithUserIntent();
let view = new MockLiveView(doc);

// User explicitly disconnects
view.toggleConnection();
expect(doc.userDisconnectedIntent).toBe(true);
expect(view.shouldConnect).toBe(false);

// User navigates away (release is called)
view.release();

// User navigates back (new LiveView is created)
// THIS IS THE BUG FIX: getViews() now uses userDisconnectedIntent
view = createLiveViewFromDocument(doc);

// New view should respect user's disconnect intent
expect(view.shouldConnect).toBe(false);
expect(doc.userDisconnectedIntent).toBe(true);
});

it("should auto-connect when user never explicitly disconnected", () => {
// Setup: Document exists, user opens it
const doc = new MockDocumentWithUserIntent();
let view = new MockLiveView(doc);
expect(view.shouldConnect).toBe(true);

// User navigates away without disconnecting
view.release();

// User navigates back
view = createLiveViewFromDocument(doc);

// New view should auto-connect (default behavior)
expect(view.shouldConnect).toBe(true);
expect(doc.userDisconnectedIntent).toBe(false);
});

it("should auto-connect after user explicitly reconnects", () => {
const doc = new MockDocumentWithUserIntent();
let view = new MockLiveView(doc);

// User disconnects then reconnects
view.toggleConnection(); // Disconnect
view.toggleConnection(); // Reconnect
expect(doc.userDisconnectedIntent).toBe(false);

// User navigates away
view.release();

// User navigates back
view = createLiveViewFromDocument(doc);

// New view should auto-connect
expect(view.shouldConnect).toBe(true);
});
});
});

/**
* Integration behavior documentation (verified manually via E2E):
*
* When user clicks disconnect toggle in LiveView:
* 1. LiveView.toggleConnection() is called
* 2. Sets this.shouldConnect = false
* 3. Sets this.document.userDisconnectedIntent = true
* 4. Calls this.document.disconnect()
*
* When user navigates away:
* 1. LiveView.release() is called
* 2. Calls this.document.disconnect()
* 3. userDisconnectedIntent is NOT changed (stays true if user disconnected)
*
* When user navigates back:
* 1. LiveViewManager.getViews() is called
* 2. Creates new LiveView with shouldConnect = !doc.userDisconnectedIntent
* 3. If user had disconnected, new LiveView has shouldConnect = false
* 4. Document stays disconnected as user intended
*
* When user clicks connect toggle:
* 1. LiveView.toggleConnection() is called
* 2. Sets this.shouldConnect = true
* 3. Sets this.document.userDisconnectedIntent = false
* 4. Calls this.document.connect()
*
* E2E verification command:
* python .claude/scripts/obsidian_debug.py --vault <name> relay file-status "test.md"
*/
11 changes: 10 additions & 1 deletion src/Canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
_tfile: TFile | null;
name: string;
userLock: boolean = false;
/**
* Tracks whether the user has explicitly disconnected this canvas via the UI toggle.
* - Default: false (canvases connect by default)
* - Set to true: only via explicit user action in RelayCanvasView.toggleConnection()
* - NOT modified by: disconnect(), release(), network issues, or connection pool limits
* - Used by: LiveViews.getViews() to preserve user's disconnect intent across navigation
*/
userDisconnectedIntent: boolean = false;
extension: string;
basename: string;
vault: Vault;
Expand Down Expand Up @@ -192,8 +200,9 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
async whenReady(): Promise<Canvas> {
const promiseFn = async (): Promise<Canvas> => {
const awaitingUpdates = await this.awaitingUpdates();
if (awaitingUpdates) {
if (awaitingUpdates && !this.userDisconnectedIntent) {
// If this is a brand new shared folder, we want to wait for a connection before we start reserving new guids for local files.
// But respect user's explicit disconnect intent.
this.log("awaiting updates");
this.connect();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you want to guard this instead.

The "awaitingUpdates" flag is really complex, but it is basically trying to prevent uses from creating multiple "CRDT roots". We don't write to the CRDT unless we we're certain that the root has synced to the server once (or we know that we created it on this machine).

I think what would happen if your PR was landed as-is is that in the case where the user toggled the connection back on (and the ready state was blocked by connectivity), they would not enter the "ready" state until whenReady() was called again (maybe by switching tabs out/in).

In contrast, if we move the gate to this line then we are still queuing up some work/promises gated on "ready", and they will resolve when the user actively connects.

await this.onceConnected();
Expand Down
11 changes: 10 additions & 1 deletion src/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export class Document extends HasProvider implements IFile, HasMimeType {
_tfile: TFile | null;
name: string;
userLock: boolean = false;
/**
* Tracks whether the user has explicitly disconnected this document via the UI toggle.
* - Default: false (documents connect by default)
* - Set to true: only via explicit user action in LiveView.toggleConnection()
* - NOT modified by: disconnect(), release(), network issues, or connection pool limits
* - Used by: LiveViews.getViews() to preserve user's disconnect intent across navigation
*/
userDisconnectedIntent: boolean = false;
extension: string;
basename: string;
vault: Vault;
Expand Down Expand Up @@ -318,8 +326,9 @@ export class Document extends HasProvider implements IFile, HasMimeType {
async whenReady(): Promise<Document> {
const promiseFn = async (): Promise<Document> => {
const awaitingUpdates = await this.awaitingUpdates();
if (awaitingUpdates) {
if (awaitingUpdates && !this.userDisconnectedIntent) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see canvas comments for same thing

// If this is a brand new shared folder, we want to wait for a connection before we start reserving new guids for local files.
// But respect user's explicit disconnect intent.
this.log("awaiting updates");
this.connect();
await this.onceConnected();
Expand Down
8 changes: 6 additions & 2 deletions src/HasProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export class HasProvider extends HasLogging {
path?: string;
ydoc: Y.Doc;
clientToken: ClientToken;
// Track if provider has ever synced. We use our own flag because
// _provider.synced can be reset to false on reconnection.
_providerSynced: boolean = false;
private _offConnectionError: () => void;
private _offState: () => void;
listeners: Map<unknown, Listener>;
Expand Down Expand Up @@ -216,7 +219,7 @@ export class HasProvider extends HasLogging {
}

public get synced(): boolean {
return this._provider.synced;
return this._providerSynced;
}

disconnect() {
Expand Down Expand Up @@ -249,11 +252,12 @@ export class HasProvider extends HasLogging {
}

onceProviderSynced(): Promise<void> {
if (this._provider.synced) {
if (this._providerSynced) {
return Promise.resolve();
}
return new Promise((resolve) => {
this._provider.once("synced", () => {
this._providerSynced = true;
resolve();
});
});
Expand Down
Loading