Skip to content

Commit 619aa5b

Browse files
Fix saving after renames (#511)
* Add a test Improve tests * Fix saving after rename * Add a warning * Handle race condition if rename and save happens quickly by having a second listener on the local `fileChaned` signal * Fix typo in a test comment Co-authored-by: David Brochart <[email protected]> --------- Co-authored-by: David Brochart <[email protected]>
1 parent c6434f6 commit 619aa5b

File tree

3 files changed

+158
-8
lines changed

3 files changed

+158
-8
lines changed

packages/docprovider-extension/src/filebrowser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export const rtcContentProvider: JupyterFrontEndPlugin<ICollaborativeContentProv
8686
user: app.serviceManager.user,
8787
trans,
8888
globalAwareness,
89-
docmanagerSettings
89+
docmanagerSettings,
90+
fileChanged: defaultDrive.fileChanged
9091
});
9192
registry.register('rtc', rtcContentProvider);
9293
return rtcContentProvider;

packages/docprovider/src/ydrive.ts

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import {
1414
import { PromiseDelegate } from '@lumino/coreutils';
1515
import { ISignal, Signal } from '@lumino/signaling';
1616

17-
import { DocumentChange, ISharedDocument, YDocument } from '@jupyter/ydoc';
17+
import {
18+
DocumentChange,
19+
ISharedDocument,
20+
StateChange,
21+
YDocument
22+
} from '@jupyter/ydoc';
1823

1924
import { WebSocketProvider } from './yprovider';
2025
import {
@@ -49,6 +54,7 @@ namespace RtcContentProvider {
4954
trans: TranslationBundle;
5055
globalAwareness: Awareness | null;
5156
docmanagerSettings: ISettingRegistry.ISettings | null;
57+
fileChanged?: ISignal<Contents.IDrive, Contents.IChangedArgs>;
5258
}
5359
}
5460

@@ -65,6 +71,7 @@ export class RtcContentProvider
6571
this.sharedModelFactory = new SharedModelFactory(this._onCreate);
6672
this._providers = new Map<string, WebSocketProvider>();
6773
this._docmanagerSettings = options.docmanagerSettings;
74+
this._driveFileChanged = options.fileChanged;
6875
}
6976

7077
/**
@@ -189,6 +196,10 @@ export class RtcContentProvider
189196
content: false
190197
};
191198
return this.get(localPath, fetchOptions);
199+
} else {
200+
console.warn(
201+
`Could not find a provider for ${localPath}, falling back to REST API save`
202+
);
192203
}
193204
}
194205

@@ -199,7 +210,7 @@ export class RtcContentProvider
199210
* A signal emitted when a file operation takes place.
200211
*/
201212
get fileChanged(): ISignal<this, Contents.IChangedArgs> {
202-
return this._ydriveFileChanged;
213+
return this._providerFileChanged;
203214
}
204215

205216
private _onCreate = (
@@ -241,13 +252,99 @@ export class RtcContentProvider
241252
this._globalAwareness?.setLocalStateField('documents', documents);
242253
}
243254

244-
const key = `${options.format}:${options.contentType}:${options.path}`;
255+
let path = options.path;
256+
let key = `${options.format}:${options.contentType}:${path}`;
245257
this._providers.set(key, provider);
246258

259+
const handlePathChange = (
260+
pathChange: StateChange<string | undefined>
261+
) => {
262+
const oldPath = pathChange.oldValue;
263+
const newPath = pathChange.newValue;
264+
if (!oldPath || !newPath) {
265+
// This is expected when shared model initializes and the path is first populated
266+
console.debug('New or old path not given', pathChange);
267+
return;
268+
}
269+
270+
const oldKey = `${options.format}:${options.contentType}:${oldPath}`;
271+
if (oldKey !== key) {
272+
console.error(
273+
'The computed old provider key is different from the current key'
274+
);
275+
return;
276+
}
277+
const newKey = `${options.format}:${options.contentType}:${newPath}`;
278+
279+
// Check if the provider is still registered (it may have been disposed if document was closed)
280+
const provider = this._providers.get(oldKey);
281+
if (!provider) {
282+
console.warn(
283+
`Could not find a provider to update after rename ${oldKey}, ${newKey}`
284+
);
285+
return;
286+
}
287+
288+
// Re-register the provider under the new key
289+
this._providers.set(newKey, provider);
290+
this._providers.delete(oldKey);
291+
292+
// Update the provider key so that it can be disposed correctly when shared document closes
293+
key = newKey;
294+
path = newPath;
295+
296+
// Update the documents field
297+
const state = this._globalAwareness?.getLocalState() || {};
298+
const documents: string[] = state.documents || [];
299+
const oldPathIndex = documents.indexOf(oldPath);
300+
if (documents.includes(oldPath) && !documents.includes(newPath)) {
301+
documents.splice(oldPathIndex, 1);
302+
documents.push(newPath);
303+
this._globalAwareness?.setLocalStateField('documents', documents);
304+
}
305+
};
306+
307+
// The information about file being renamed can come from two places:
308+
// - from the sharedModel via changed signal with documentChange
309+
// - from the fileChanged signal of the drive
310+
// Neither method is foolproof:
311+
// - the shared model approach can be delayed as the change needs to be
312+
// reflected by the server and come back, in which case we get a race condition
313+
// - the fileChanged signal is emitted with a larger delay for renames of collaborators
314+
// Thus we need both.
315+
const handleFileChangedSignal = (
316+
_: Contents.IDrive,
317+
change: Contents.IChangedArgs
318+
) => {
319+
if (change.type !== 'rename') {
320+
return;
321+
}
322+
const oldPath = change.oldValue?.path;
323+
const newPath = change.newValue?.path;
324+
if (oldPath !== path) {
325+
return;
326+
}
327+
handlePathChange({
328+
oldValue: oldPath,
329+
newValue: newPath,
330+
name: 'path'
331+
});
332+
};
333+
334+
this._driveFileChanged?.connect(handleFileChangedSignal);
335+
247336
sharedModel.changed.connect(async (_, change) => {
248337
if (!change.stateChange) {
249338
return;
250339
}
340+
341+
const pathChanges = change.stateChange.filter(
342+
change => change.name === 'path'
343+
);
344+
for (const pathChange of pathChanges) {
345+
handlePathChange(pathChange);
346+
}
347+
251348
const hashChanges = change.stateChange.filter(
252349
change => change.name === 'hash'
253350
);
@@ -267,7 +364,7 @@ export class RtcContentProvider
267364
const newPath = sharedModel.state.path ?? options.path;
268365
const model = await this.get(newPath as string, { content: false });
269366

270-
this._ydriveFileChanged.emit({
367+
this._providerFileChanged.emit({
271368
type: 'save',
272369
newValue: { ...model, hash: hashChange.newValue },
273370
// we do not have the old model because it was discarded when server made the change,
@@ -285,12 +382,15 @@ export class RtcContentProvider
285382

286383
// Remove the document path from the list of opened ones for this user.
287384
const state = this._globalAwareness?.getLocalState() || {};
288-
const documents: any[] = state.documents || [];
289-
const index = documents.indexOf(options.path);
385+
const documents: string[] = state.documents || [];
386+
const index = documents.indexOf(path);
290387
if (index > -1) {
291388
documents.splice(index, 1);
292389
}
293390
this._globalAwareness?.setLocalStateField('documents', documents);
391+
392+
// Disconnect signal
393+
this._driveFileChanged?.disconnect(handleFileChangedSignal);
294394
});
295395
} catch (error) {
296396
// Falling back to the contents API if opening the websocket failed
@@ -306,7 +406,10 @@ export class RtcContentProvider
306406
private _trans: TranslationBundle;
307407
private _globalAwareness: Awareness | null;
308408
private _providers: Map<string, WebSocketProvider>;
309-
private _ydriveFileChanged = new Signal<this, Contents.IChangedArgs>(this);
409+
// This is for emitting signals to be proxied to `Drive.fileChanged`
410+
private _providerFileChanged = new Signal<this, Contents.IChangedArgs>(this);
411+
// This is for listening to `Drive.fileChanged` signal
412+
private _driveFileChanged?: ISignal<Contents.IDrive, Contents.IChangedArgs>;
310413
private _serverSettings: ServerConnection.ISettings;
311414
private _docmanagerSettings: ISettingRegistry.ISettings | null;
312415
}

ui-tests/tests/notebook.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,52 @@ test.describe('Initialization', () => {
146146
await page.notebook.close(true);
147147
await guestPage.notebook.close(true);
148148
});
149+
150+
test('Rename a notebook', async({ page, tmpPath }) => {
151+
await page.filebrowser.refresh();
152+
await page.notebook.open(exampleNotebook);
153+
await page.notebook.activate(exampleNotebook);
154+
155+
// Enter edit mode on cell number three
156+
await page.notebook.enterCellEditingMode(3);
157+
expect(await page.notebook.isCellInEditingMode(3)).toBe(true);
158+
159+
const oldPath = `${tmpPath}/${exampleNotebook}`;
160+
const newPath = `${tmpPath}/differentName.ipynb`;
161+
162+
// Rename notebook
163+
await page.contents.renameFile(oldPath, newPath);
164+
165+
// Intercept REST API save endpoint
166+
let savedUsingRestAPI = false;
167+
const saveRoutePattern = `**/api/contents/${newPath}**`;
168+
await page.route(saveRoutePattern, route => {
169+
if (route.request().method() === 'PUT') {
170+
savedUsingRestAPI = true;
171+
}
172+
route.continue();
173+
});
174+
175+
// Save the notebook manually
176+
await page.notebook.save();
177+
178+
// Wait three seconds to make sure the cell does not jump
179+
await page.waitForTimeout(3000);
180+
181+
// The save operation should not trigger a PUT to the renamed path,
182+
// instead the save request should have gone through the websocket.
183+
expect.soft(savedUsingRestAPI).toBe(false);
184+
185+
// This tests against a out-of-band reloads that can cause
186+
// change of active cell and of the notebook edit/command mode
187+
expect(await page.notebook.isCellInEditingMode(3)).toBe(true);
188+
189+
// Cleanup network route
190+
await page.unroute(saveRoutePattern);
191+
192+
// Rename it back for cleanup
193+
await page.contents.renameFile(newPath, oldPath);
194+
});
149195
});
150196

151197
test.describe('Ten clients', () => {

0 commit comments

Comments
 (0)