-
Notifications
You must be signed in to change notification settings - Fork 18
Fix: unwanted note reconnect on navigation #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Cfeusier
wants to merge
8
commits into
main
Choose a base branch
from
cf/onboarding-environ
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
828c4ac
fix: Mark HasProvider as synced if it has ever synced this session
dtkav ec7ac94
fix: Don't block LiveView creation on folder.ready
dtkav 7f501df
feat: Add metrics infrastructure for obsidian-metrics integration
dtkav 61b5991
feat: Track IndexedDB document size and compaction metrics
dtkav ce8262a
fix: Don't try to reconnect to realtime continuously on auth failure
dtkav dbde61d
refactor: Consolidate banner/button logic into Banner class
dtkav f8c1818
fix: Preserve user disconnect intent across navigation
Cfeusier 58a670b
Add dev.sh and .claude to .gitignore (remove old CR chars)
Cfeusier File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.