Description
TPAC highlighted a pretty substantial architectural difference between the Chromium and Firefox implementations of this API. Chromium's implementation maps a FileSystemHandle
to a file path (which I had attempted to codify a while back), while Firefox's implementation suggests "they are references to objects resolved at creation time."
This has gone unnoticed until this point because no features have exposed this difference to the web.
But there are some significant web-observable implications to this choice, most notably around directory moves. Consider the following code, which moves a directory containing a child we have a handle to:
// Create file at /old/file.txt.
const dir_entry = root.getDirectoryHandle('old', { create: true });
const file_entry = dir_entry.getFileHandle('file.txt', { create: true });
// The file previously pointed to file at /old/file.txt now resides at /new/file.txt.
await dir_entry.move('new');
What happens next?
// PATH-BASED: The promise rejects with NotFoundError.
// REF-BASED: The call succeeds and returns the File.
await file_entry.getFile();
// PATH-BASED: The promise rejects with NotFoundError.
// REF-BASED: The promise resolves successfully and creates a new writable stream.
await file_entry.createWritable();
// PATH-BASED: The promise resolves to `null`.
// REF-BASED: The promise resolves to 'file.txt' (same as before the move).
await dir_entry.resolve(file_entry);
// PATH-BASED: The isSameEntry() promise resolves to `false`.
// REF-BASED: The isSameEntry() promise resolves to `true`.
const other_file = await dir_entry.getFileHandle('file.txt');
await other_file.isSameEntry(file_entry);
// PATH-BASED: The directory's ID has changed, along with its path.
// REF-BASED: The directory's ID remains unchanged since before the move.
await dir_entry.getUniqueId();
// PATH-BASED: The file's ID remains unchanged, along with its path.
// REF-BASED: The file's ID remains unchanged since before the move.
await file_entry.getUniqueId();
// This method is proposed in https://github.com/whatwg/fs/issues/38
// PATH-BASED: The promise presumably will resolve to `null`.
// REF-BASED: The promise presumably will resolve to a copy of `dir_entry`.
await file_entry.getParent();
// PATH-BASED: getFile() is once again valid and returns the file at /old/file.txt.
// The isSameEntry() promise resolves to `true`.
// REF-BASED: getFile() returns the file at /new/file.txt.
// The isSameEntry() promise resolves to `false`.
const old_dir = await root.getDirectoryHandle('old', { create: true });
const other_file = await old_dir.getFileHandle('file.txt', { create: true });
await file_entry.getFile();
await other_file.isSameEntry(file_entry);
Directory moves very blatantly expose this difference. It will be basically impossible to specify directory moves in a way that's consistent across platforms without being much more specific here. This also has implications for at least the getUniqueId()
method and removed handles. Directory moves would also expose a difference in resolve()
and a discussed-but-not-yet-formally-proposed getParent()
method, among other things.
Looking at the code above, I tend to agree that a ref-based approach leads to outcomes which are more likely to align with user expectations, at least regarding directory moves. It would be nice if moving a directory didn't invalidate all child handles, for example. Meanwhile, there are some instances where a path-based approach arguably makes more sense. What happens to a FileSystemHandle
when its underlying file is removed?
// Create a file.
const file_entry = root.getFileHandle('file.txt', { create: true });
// Remove the file.
await file_entry.remove();
// PATH-BASED: We can still write to a removed file.
// REF-BASED: ????
await file_entry.createWritable();
// PATH-BASED: The isSameEntry() promise resolves to `true`.
// REF-BASED: The isSameEntry() promise resolves to `false`, presumably?
const other_file = root.getFileHandle('file.txt', { create: true });
await other_file.isSameEntry(file_entry);
That being said, it seems reasonable to specify that a handle can still be written to if it's removed via this API, while handles removed by other means (such as by clearing site data or via an OS file manager) could be invalidated.
In summary...
One option is to never specify features that expose this implementation difference, such as directory moves. Unfortunately this is a pretty fundamental difference which I suspect will be hard to paper over as the API continues to evolve. To me, this is a very unsatisfying option. Consider a web IDE which just wants to rename src/foo/
to src/bar/
, but is forced to recursively copy all contained files.
Another theoretical option is to accept that there will be cross-browser differences. Sure, moving a directory will invalidate all child handles, but you can re-acquire all the handles within the new directory. However, going down this path is bound to expose many more subtle cross-browser differences. For example, Chromium locks all ancestor directories when a file has an open writable to ensure its path does not change while it is being written to. Having an open writable will block moving a parent directory, which would be a confusing restriction in a reference-based design. This option would be bad for developers and the impacted users, but just listing it here for completeness.
The other option is to specify a requirement that a FileSystemHande
is a reference (in the same way I had attempted to specify it’s a path here). That framework could be used to specify new methods such as move()
, remove()
and getUniqueId()
in a way that’s consistent across browsers. This is our preferred option, but supporting this in Chromium would require a pretty substantial re-architecture that I'm hesitant to commit to without clear indication from other browsers and the developer community that handles should be based on references rather than paths...
@szewai does WebKit have an opinion here?