Skip to content
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

[RFC] [cross_file] New architecture. #7591

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

ditman
Copy link
Member

@ditman ditman commented Sep 6, 2024

This PR attempts to solve the current issues of XFile by slightly re-architecting it.

In this PR:

  • XFile is now an interface that does not have any code, except for the two "legacy" constructors that are maintained (but deprecated) for backwards-compatibility reasons.
  • Each underlying data structure contains a super simple implementation of the interface.
  • XFile instances are now created from the platform-specific (native vs web) factories.

This does not remove saveAs from the XFile interface.

Note

This is a breaking change because I've made the old XFile constructors deprecated (and throwy) but if we want to, we could possibly re-implement the "old" constructors with the new implementations and a few conditional imports?

This is not a breaking change anymore. Old tests continue passing (test/legacy*), and new tests contain minor changes.

Anyway, just a RFC!

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@ditman
Copy link
Member Author

ditman commented Sep 6, 2024

/cc @stuartmorgan this is my idea for the "next" version of the x-files. PTAL and let me know what you think, or if you want this to go in another direction :)

The_Truth_Is_Out_There_tagline


@override
Future<void> saveTo(String path) async {
final File fileToSave = File(path);
Copy link
Member Author

Choose a reason for hiding this comment

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

This is lifted from the old implementation but... should the File be created from path + this.name?


/// A CrossFile backed by a dart:io [File].
///
/// (Mobile-only).
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't forget desktop :)

Suggested change
/// (Mobile-only).
/// This is only supported on non-web platforms.

/// The [path] variable is ignored.
@override
Future<void> saveTo(String path) async {
// Save a Blob to file...
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment seems a bit redundant.

objects and their URLs.

It seems that Safari hangs when reading Blobs larger than 4GB (your app will stop
without returning any data, or throwing an exception).

This package will attempt to throw an `Exception` before a large file is accessed
Copy link
Contributor

Choose a reason for hiding this comment

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

Just to be sure, was this comment removed, because we now refer to the browser's behavior? Or was this fixed in recent versions of Safari?

Copy link
Member Author

Choose a reason for hiding this comment

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

We don't explicitly throw the "4 Gigabyte" exception; the browser may fail in unexpected ways (depending on what we end up doing with the bytes of the file)


Example:
In order to instantiate a new `XFile`, import the correct factory class,
either from `package:cross_file/native/factory.dart` (for native development) or
Copy link
Contributor

Choose a reason for hiding this comment

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

Small drive-by comment:

Since the two implementations now have platform-specific imports (dart:io or package:web)
users who take the imports at face value might run into compilation issues, because they lack a conditional import?

Can we provide guidance on how to avoid this? With the legacy API we had the File or Blob types as internals and not on the public interface, which allowed for the plugin to provide a conditional import shim.

Copy link
Member Author

Choose a reason for hiding this comment

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

@navaronbracke thanks for the comment! This is a very interesting problem, for which I don't have a great answer. The TL;DR/Guidance is:

  • Are you reading XFiles? Use the XFile interface only.
  • Are you creating XFiles?
    • In mobile? Use the native/factory.dart
    • In web? Use the web/factory.dart

Each factory exposes constructors that are platform-specific, and we really don't have a cross-platform file constructor.

What problems do you think people are going to have? Is this a documentation problem, or a design issue?

Copy link
Contributor

Choose a reason for hiding this comment

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

If people take the comment at face value and do import "package:cross_file/web/factory.dart"; directly, they will run into errors like library dart:js_interop is not available on this platform. Since they don't directly import dart:js_interop, this might be confusing to new users.

I think this is merely a documentation issue. We should put emphasis on:

  1. If this is in a platform-specific plugin implementation, you can probably import it directly (as the plugin system only uses the platform implementations of your plugin on the correct platforms)
  2. If this is in a Flutter app, you'll have to use a conditional import on if (dart.library.js_interop) ... and you probably need an extra abstraction, since the web-only types (i.e. Blob) cannot be imported in native implementations (for the same reason why the plugin cannot provide the conditional import here)

Copy link
Member Author

Choose a reason for hiding this comment

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

OK, if it's a documentation issue, it's probably easier to fix (LOL famous last words :P)

Do you have any ideas with how this comment would be clearer? What would you like to read?

(I'll try to come up with something more specific as well!)

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, the main point is that the compiler itself would want you to use a conditional import.
So perhaps something like:

"In order to instantiate a new XFile, import the correct factory class, through a conditional import.
Using a conditional import is required to use types that are only available on the current platform.

import 'package:cross_file/native/factory.dart'
 if (dart.library.js_interop) 'package:cross_file/web/factory.dart'

"

The wording is just a suggestion, so feel free to tweak it.

Copy link
Contributor

@navaronbracke navaronbracke Sep 25, 2024

Choose a reason for hiding this comment

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

Hmm, wait. Since the API for both factories is divergent, would a user that uses this conditional import know at compile time if they could access the fromFile() (both with a dart:io or package:web File) or the fromBlob()/fromObjectUrl() methods? I think that Dart will only provide the base type XFileFactory, but I think you don't get a guarantee for the available methods.

Perhaps we can fix that by having the interface like:

  • XFileFactory is the base type that is exported through a conditional export. It provides all the methods that do not include platform-specific types (so only fromPath/fromBytes/fromObjectUrl)
  • WebXFileFactory and NativeXFileFactory implement that interface
  • WebXFileFactory provides the fromBlob/fromFile implementations for package:web types
  • NativeXFileFactory provides the fromFile implementation for dart:io
  • unsupported methods on a platform just throw UnsupportedError
  • we direct users to use kIsWeb and/or a type check on the factory
  • since we do the conditional export, the comment above can stay unchanged, no need to adjust the wording when we provide the export ourselves. We'll have to add guidance for checking which factory is provided instead (simple type check should do)

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the clarification @navaronbracke! I see what you mean! You want the XFileFactory to be cross-platform as well!

IMO the only cross-platform class from this package is the XFile interface! 1

If your cross-platform App needs to "create XFiles", you'll end up with some platform-aware code (because as you said, you can't import web stuff in native code), like this:

// Your app's main.dart
import 'src/get_file/get_file.dart'; // Platform-aware, see below

// ...

final XFile myFile = await getFile(/* config? data? */);

With a directory like this:

src/
  get_file/
    get_file.dart
    get_file_web.dart
    get_file_native.dart

Your get_file.dart could conditionally export the right file for the platform; like:

// get_file.dart

export "get_file_native.dart"
  if (dart.library.js_interop) "get_file_web.dart";

So each implementation file can use the platform-specific factories:

get_file_native.dart get_file_web.dart
import 'package:cross_file/native/factory.dart';

XFile getFile(/* config? data? */) async {
  // Do the right thing for native here, maybe
  // we write the data to a File in a temp dir
  // and then:
  return XFileFactory.fromFile(tmpFile);
}
import 'package:cross_file/web/factory.dart';

XFile getFile(/* config? data? */) async {
  // Do the right thing for the web here,
  // maybe we grab bytes, put them into a
  // Blob and do:
  return XFileFactory.fromBlob(dataBlob);
}

Maybe the confusing part is calling the XFileFactory the same on both native and web? Should we just have NativeXFileFactory and WebXFileFactory to make the separation clearer, and signal that we never expect a shared interface across both classes?


  • It provides all the methods that do not include platform-specific types (so only fromPath/fromBytes/fromObjectUrl)

This is why I think having a base class for the XFileFactory is more trouble than what it's worth. Unless I'm missing something, for this approach to work, the XFileFactory needs to be an actual instance that must be replaced at run-time (similar to a federated plugin?).

Since we can't expose web-only types through this common API, users would still need to conditionally import the native/web types to cast the instance to the one that has the platform specific methods. And if you're conditionally importing, just import the right XFileFactory static class? 😛

Footnotes

  1. This is similar to the Client interface from package:http that has multiple platform-specific implementations coming from different packages

@stuartmorgan
Copy link
Contributor

Quick note to say I haven't forgotten about this, I just haven't had time to fully context switch to give this the attention it will need. I'll try to do it within the next week.

Copy link
Contributor

@stuartmorgan stuartmorgan left a comment

Choose a reason for hiding this comment

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

I'll try to do it within the next week.

😬 Time is an illusion, review time doubly so.

I like this a lot; if we don't want to jump to a breaking change (although I'm not 100% sure this isn't breaking in some obscure ways; we should probably do a quick check of GitHub to see if anyone is subclassing the old structure in any way that isn't compatible and if not call it non-breaking) this looks like a solid implementation.

I do wish we could do static extensions on XFile itself, but since we can't I do like your factory class approach more than what I had originally envisioned which was people interacting directly with subclass constructors. The way your version makes the subclass hidden implementation details that we can adjust over time is much better than that.

Thanks for tackling this, and sorry it took me so long to review!


Example:
In order to instantiate a new `XFile`, import the correct factory class,
either from `package:cross_file/native/factory.dart` (for native development) or
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like we could just do this automatically from cross_file.dart with conditional imports. The consuming code wouldn't be cross-platform because the factories are specific, but it wouldn't need to do an extra import.

Copy link
Member Author

@ditman ditman Nov 8, 2024

Choose a reason for hiding this comment

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

The problem with conditional exports/imports is that the analyzer freaks out when the APIs are different, which in this case they are (see this comment). Or do you have something else in mind?


/// Get the path of the picked file.
///
/// **Not all `XFile` instances have a `path`.**
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like we should bite the bullet and do a breaking change to make this and name nullable. But I guess if we can do the rest as a non-breaking change we could make that change later instead, at the same time that we remove the deprecated methods, giving clients the option of supporting a major version range and decreasing conflicts.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think the changes in this package are sufficiently big to do a breaking change, with all the changes we might want.

I just did a non-breaking change "because I could", but as you said above, it might be "subtly breaking elsewhere".

How do you feel about making this a breaking change? There would be extra work in revisiting the consumers of this package and fixing those (which would also need a major version bump because they tend to re-export XFile as well :S)

I don't mind tackling that as well, LMK if you're okay with the change!

Copy link
Contributor

Choose a reason for hiding this comment

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

There would be extra work in revisiting the consumers of this package and fixing those (which would also need a major version bump because they tend to re-export XFile as well :S)

Yes, working that through the ecosystem could be kind of rough. I think since your version doesn't make any real structural compromises to make a probably-not-breaking change, I want to dig into usage a bit. My gut is that nobody is doing complex subclassing that would be affected by it, but I want to try to validate that against at least the public code I can find, and especially some popular packages. If it look like it'll be safe (e.g., nobody is ever subclassing any of the classes), then we can do this version and make things easier on everyone.

Copy link
Member Author

Choose a reason for hiding this comment

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

There's some subclassing going on, most of it to add extra attributes/methods to the underlying XFile instance (they could wrap the underlying XFile and add stuff to it, or use extension methods I think?), one that could maybe migrated to one of the new web constructors, one used to store the file in "Hive", and one that is importing from src (this is illegal!)


I could create issues in their respective owner's repos to point them to this PR so they can see what's coming before we land it?

Copy link
Contributor

@stuartmorgan stuartmorgan Nov 8, 2024

Choose a reason for hiding this comment

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

That looks like we would break existing packages, unfortunately. Warning people in advance isn't enough for that, we'd need to version accordingly.

So I think the options are:

  1. Do a major version change and deal with the resulting pain.
  2. Make the new interface a super-interface of XFileBase, and let individual packages adopt that incrementally (although that would still be a breaking change for the packages that export it in their API).

The main drawback to 2 is that we need to change the name of the class everyone uses, and we would be stuck with the legacy bits for however long we want to support both.

Copy link
Member Author

@ditman ditman Nov 8, 2024

Choose a reason for hiding this comment

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

I think I prefer 1.

With the current implementation, the package is backwards-compatible for ourselves, so the transition for our packages should be relatively painless (and we could allow a version range).

(Unless we want to do the nullability changes at the same time, of course :))

Copy link
Contributor

Choose a reason for hiding this comment

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

If we're going to do a major version change, that's our chance to fix nullability; if we don't do it then we probably never will.

Copy link
Member Author

Choose a reason for hiding this comment

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

OK, I'll do all the incompatible changes in one go. Can you think of anything else? Maybe remove the saveTo method from the class, and make it a helper instead?


/// The MIME-type of the file.
///
/// **Not all `XFile` instances have a `mimeType`.**
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need to say this since it's nullable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants