2121 * - `cancel(volumeId)` clears the cycle without touching the connection.
2222 */
2323
24+ import { untrack } from 'svelte'
2425import { SvelteMap } from 'svelte/reactivity'
2526import { listen , type UnlistenFn } from '@tauri-apps/api/event'
2627import { reconnectSmbVolume } from '$lib/tauri-commands'
@@ -88,28 +89,41 @@ class SmbReconnectManager {
8889 * Subscribes a viewer (typically a FilePane) to this volume's reconnect
8990 * cycle. The optional `onSuccess` callback fires when the cycle completes.
9091 * Returns an unsubscribe function — call it on volume change / unmount.
92+ *
93+ * Gotcha/Why: every method that both reads and writes the SvelteMap is
94+ * wrapped in `untrack`. Without it, calling `subscribe` from a Svelte
95+ * `$effect` would track the `map.get(volumeId)` read, then the subsequent
96+ * `map.set` would invalidate that dep, the effect would re-run, and we'd
97+ * be in a tight subscribe→unsubscribe loop that pegs the main thread (verified
98+ * — both panes stuck on Loading…). `untrack` decouples our internal map
99+ * accesses from the caller's reactive context. Reactive readers like the
100+ * `getState`-backed `$derived` still work because `untrack` only suppresses
101+ * read tracking; writes still fire invalidations to anyone with a tracked dep.
91102 */
92103 subscribe ( volumeId : string , onSuccess ?: ( ) => void ) : ( ) => void {
93- let entry = this . map . get ( volumeId )
94- if ( ! entry ) {
95- entry = freshEntry ( )
96- this . map . set ( volumeId , entry )
97- }
98- entry . refcount ++
99- if ( onSuccess ) entry . successCallbacks . add ( onSuccess )
100- log . debug ( 'subscribe({volumeId}): refcount={refcount}' , { volumeId, refcount : entry . refcount } )
101-
102- return ( ) => {
103- const e = this . map . get ( volumeId )
104- if ( ! e ) return
105- e . refcount --
106- if ( onSuccess ) e . successCallbacks . delete ( onSuccess )
107- log . debug ( 'unsubscribe({volumeId}): refcount={refcount}' , { volumeId, refcount : e . refcount } )
108- if ( e . refcount <= 0 ) {
109- if ( e . timerId ) clearTimeout ( e . timerId )
110- this . map . delete ( volumeId )
104+ return untrack ( ( ) => {
105+ let entry = this . map . get ( volumeId )
106+ if ( ! entry ) {
107+ entry = freshEntry ( )
108+ this . map . set ( volumeId , entry )
111109 }
112- }
110+ entry . refcount ++
111+ if ( onSuccess ) entry . successCallbacks . add ( onSuccess )
112+ log . debug ( 'subscribe({volumeId}): refcount={refcount}' , { volumeId, refcount : entry . refcount } )
113+
114+ return ( ) =>
115+ untrack ( ( ) => {
116+ const e = this . map . get ( volumeId )
117+ if ( ! e ) return
118+ e . refcount --
119+ if ( onSuccess ) e . successCallbacks . delete ( onSuccess )
120+ log . debug ( 'unsubscribe({volumeId}): refcount={refcount}' , { volumeId, refcount : e . refcount } )
121+ if ( e . refcount <= 0 ) {
122+ if ( e . timerId ) clearTimeout ( e . timerId )
123+ this . map . delete ( volumeId )
124+ }
125+ } )
126+ } )
113127 }
114128
115129 /** Reactive read of the current cycle state, or `null` if no cycle is running. */
@@ -137,13 +151,15 @@ class SmbReconnectManager {
137151 * No-op if a cycle is already running for this volume.
138152 */
139153 startCycle ( volumeId : string ) : void {
140- let entry = this . map . get ( volumeId )
141- if ( ! entry ) {
142- entry = freshEntry ( )
143- this . map . set ( volumeId , entry )
144- }
145- if ( entry . timerId !== null || entry . state . status === 'attempting' ) return
146- this . beginAttempt ( volumeId , 0 )
154+ untrack ( ( ) => {
155+ let entry = this . map . get ( volumeId )
156+ if ( ! entry ) {
157+ entry = freshEntry ( )
158+ this . map . set ( volumeId , entry )
159+ }
160+ if ( entry . timerId !== null || entry . state . status === 'attempting' ) return
161+ this . beginAttempt ( volumeId , 0 )
162+ } )
147163 }
148164
149165 /**
@@ -154,14 +170,16 @@ class SmbReconnectManager {
154170 * Disabled during `attempting` (the button itself is disabled in the view).
155171 */
156172 retryNow ( volumeId : string ) : void {
157- const entry = this . map . get ( volumeId )
158- if ( ! entry ) return
159- if ( entry . state . status === 'attempting' ) return
160- if ( entry . timerId ) {
161- clearTimeout ( entry . timerId )
162- entry . timerId = null
163- }
164- void this . runAttempt ( volumeId , 0 )
173+ untrack ( ( ) => {
174+ const entry = this . map . get ( volumeId )
175+ if ( ! entry ) return
176+ if ( entry . state . status === 'attempting' ) return
177+ if ( entry . timerId ) {
178+ clearTimeout ( entry . timerId )
179+ entry . timerId = null
180+ }
181+ void this . runAttempt ( volumeId , 0 )
182+ } )
165183 }
166184
167185 /**
@@ -170,49 +188,58 @@ class SmbReconnectManager {
170188 * reconnect path will pick up.
171189 */
172190 cancel ( volumeId : string ) : void {
173- const entry = this . map . get ( volumeId )
174- if ( ! entry ) return
175- if ( entry . timerId ) clearTimeout ( entry . timerId )
176- entry . timerId = null
177- entry . state = freshState ( )
178- // Force reactivity by re-setting the entry with a new state object.
179- this . map . set ( volumeId , entry )
191+ untrack ( ( ) => {
192+ const entry = this . map . get ( volumeId )
193+ if ( ! entry ) return
194+ if ( entry . timerId ) clearTimeout ( entry . timerId )
195+ entry . timerId = null
196+ entry . state = freshState ( )
197+ // Force reactivity by re-setting the entry with a new state object.
198+ this . map . set ( volumeId , entry )
199+ } )
180200 }
181201
182202 // ── Internal ──────────────────────────────────────────────────────
203+ // All map-mutating internals run inside `untrack` so a Svelte reactive
204+ // caller never ends up tracking our internal `map.get` reads.
183205
184206 private handleDisconnected ( volumeId : string ) : void {
185- const entry = this . map . get ( volumeId )
186- if ( ! entry ) return // No subscribers — lazy reconnect handles it on next nav.
187- if ( entry . timerId !== null || entry . state . status === 'attempting' ) return
188- this . beginAttempt ( volumeId , 0 )
207+ untrack ( ( ) => {
208+ const entry = this . map . get ( volumeId )
209+ if ( ! entry ) return // No subscribers — lazy reconnect handles it on next nav.
210+ if ( entry . timerId !== null || entry . state . status === 'attempting' ) return
211+ this . beginAttempt ( volumeId , 0 )
212+ } )
189213 }
190214
191215 private handleDirect ( volumeId : string ) : void {
192- const entry = this . map . get ( volumeId )
193- if ( ! entry ) return
194- // Idempotent: if no cycle is in flight (state is the baseline + no timer),
195- // there's nothing to clean up and no subscribers to notify. This guards
196- // against double-firing when both `runAttempt`'s success branch and the
197- // `smb-connection-changed` event fire — whichever runs first wins.
198- const noActiveCycle = entry . state . status === 'waiting' && entry . timerId === null && entry . state . attemptIndex === 0
199- if ( noActiveCycle ) return
200- if ( entry . timerId ) clearTimeout ( entry . timerId )
201- entry . timerId = null
202- entry . state = freshState ( )
203- this . map . set ( volumeId , entry ) // notify subscribers
204- for ( const cb of entry . successCallbacks ) {
205- try {
206- cb ( )
207- } catch ( e ) {
208- log . warn ( 'Reconnect success callback threw: {error}' , { error : String ( e ) } )
216+ untrack ( ( ) => {
217+ const entry = this . map . get ( volumeId )
218+ if ( ! entry ) return
219+ // Idempotent: if no cycle is in flight (state is the baseline + no timer),
220+ // there's nothing to clean up and no subscribers to notify. This guards
221+ // against double-firing when both `runAttempt`'s success branch and the
222+ // `smb-connection-changed` event fire — whichever runs first wins.
223+ const noActiveCycle = entry . state . status === 'waiting' && entry . timerId === null && entry . state . attemptIndex === 0
224+ if ( noActiveCycle ) return
225+ if ( entry . timerId ) clearTimeout ( entry . timerId )
226+ entry . timerId = null
227+ entry . state = freshState ( )
228+ this . map . set ( volumeId , entry ) // notify subscribers
229+ for ( const cb of entry . successCallbacks ) {
230+ try {
231+ cb ( )
232+ } catch ( e ) {
233+ log . warn ( 'Reconnect success callback threw: {error}' , { error : String ( e ) } )
234+ }
209235 }
210- }
236+ } )
211237 }
212238
213239 /**
214240 * Schedules attempt `attemptIndex` after the corresponding backoff delay.
215- * Sets `status='waiting'` and the progress-bar timing fields.
241+ * Sets `status='waiting'` and the progress-bar timing fields. Caller is
242+ * responsible for the surrounding `untrack` (the public methods all are).
216243 */
217244 private beginAttempt ( volumeId : string , attemptIndex : number ) : void {
218245 const entry = this . map . get ( volumeId )
0 commit comments