-
Notifications
You must be signed in to change notification settings - Fork 927
Make View processing logic optional #3561
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
Conversation
💥 No ChangesetLatest commit: 7f52b3c Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂 If these changes should be published to npm, you need to add a changeset. This PR includes no changesetsWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Click here to learn what changesets are, and how to add one. Click here if you're a maintainer who wants to add a changeset to this PR |
Binary Size ReportAffected SDKs
Test Logs |
fceba12
to
aa4fc7f
Compare
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.
This would be significantly easier to review if you made the structural changes related to tree shaking separately from the logic changes required to make view processing separable.
On the whole this doesn't seem awful, but I think we'd benefit from having a standard, concise way of describing demand-loaded types without relying on interfaces and classes implementing interfaces. I've made some concrete suggestions below.
* `emitNewSnapshotsAndNotifyLocalStore()`, but on Web it is extracted to | ||
* ensure that all view logic only exists in bundles that include views. | ||
*/ | ||
docsChangeListener: |
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.
Since this is essentially just a way to make an indirection between emitNewSnapsAndNotifyLocalStore
and the actual implementation is always going to be applyDocChanges
, why not just call this applyDocChanges
? This avoids the indirection through a more-general-than-necessary concept and makes it easier to read through it.
You might also consider having a noopApplyDocChanges
that's assigned here by default. That gives you two things: you can lose the null check and you can make the type just typeof noopApplyDocChanges
, though I wonder if you could make it typeof applyDocChanges
without incurring the bundling penalty.
Failing either of those things, a named type could make this easier to understand and reason about: if you named it an ApplyDocChangesHandler
, this would be
applyDocChanges: ApplyDocChangesHandler | null
... which more directly names what's expected.
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 renamed this to applyDocChanges
and created a type definition. I ended up leaving this listener as optional as this allows me to verify that the code is injected by the time it is used.
queryView.targetId, | ||
viewSnapshot.limboChanges | ||
); | ||
} | ||
return viewSnapshot; | ||
} | ||
|
||
/** |
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.
The old ordering of listen, initializeViewAndComputeSnapshot, then applyRemoteEvent reads better because it followed the logical sequence of events you might encounter at runtime. This new ordering isn't alphabetical and doesn't seem to have any rhyme or reason to it that I could find.
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.
The ordering is mostly "organic". I tried to keep public methods on top, but probably failed a couple times (and the multi-tab methods are still grouped together at the bottom of this file). I re-ordered them as suggested.
import { | ||
SyncEngine, | ||
SyncEngineListener, | ||
listen as syncEngineListen, |
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'm not a fan of this kind of renaming internally. It makes it hard to search the codebase textually.
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 renamed the original functions.
.then(() => removeAndCleanupTarget(syncEngineImpl, targetId, err)) | ||
.catch(ignoreIfPrimaryLeaseLoss); | ||
} | ||
} |
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.
Missing newline.
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.
Fixed
return viewChange.snapshot; | ||
} | ||
|
||
async function applyDocChanges( |
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.
Optional: consider grouping this immediately below emitNewSnapsAndNotifyLocalStore
since that's the logical flow (even though the linkage is indirect). The suggestion is based on the idea that logical flow is more important than grouping this with the function that happens to install it in the sync engine (which seems arbitrary).
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.
emitNewSnapsAndNotifyLocalStore
is not a free standing function (yet). It likely won't need to be one for size savings (since most codepaths rely on it), but we can make it one for consistency.
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.
We might as well just adopt the one style so you don't have to think about whether a given operation is a method or a free function.
@@ -55,6 +55,7 @@ import { | |||
import { ByteString } from '../util/byte_string'; | |||
import { isIndexedDbTransactionError } from '../local/simple_db'; | |||
import { User } from '../auth/user'; | |||
import { rejectListen, applyRemoteEvent } from '../core/sync_engine'; |
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.
This creates a strong cycle between the SyncEngine and the RemoteStore (and also breaks our ability to inject an alternate implementation for testing, though I don't think we've ever done that).
Could you leave the RemoteSyncer interface as having those two methods, and then make function pointers of those names members of SyncEngine
so that it still implements the interface, just not via its prototype?
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 thought long about this (especially since this change made the mocking in the EventListener test much more complex). I don' think there is a way to achieve the code savings that I am after if we use one shared interface.
Instead, we would have to split RemoteSyncer into at least two interfaces (one for write handling, one for watch). On top of that, we would also need to inject the actual implementation at runtime, similar to what we are now doing in SyncEngine. If we provided the implementation at instantiation, the code would be included no matter what.
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.
Don't we have to solve the cycle problem though? Given all the trouble we've been having with random reorderings breaking things it seems unwise for us to intentionally create such cycles.
Meanwhile, what I'm proposing is that we have RemoteStore
have a RemoteSyncer
member whose members are fields that can be assigned. Then in the syncEngine once we get to a place where those callbacks are going to be required in the RemoteStore we assign to the fields. This gives essentially the same split you get here while preserving the flexibility in tests.
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 updated the PR to move all SyncEngine logic into free-standing functions. This makes the diff even larger, but it is actually easier to review (especially in IntelliJ, which makes it pretty easy to figure out the code that actually changed). I also re-added the RemoteSyncer interface.
I wasn't able to come up with a way to get the unit tests for EventManager to pass without adding another interface that let's me replace the sync engine callbacks with custom test functions. If I don't do that, I have to bring up a full instance of SyncEngine and a full instance of LocalStore, which requires mocking all of persistence. Assigning sync engine's |
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.
Basically LGTM though with some final questions.
return getOnlineComponentProvider(firestore).then( | ||
components => components.eventManager | ||
); | ||
return getOnlineComponentProvider(firestore).then(components => { |
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.
No need to address this here, necessarily, but why not write these with async/await?
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 don't remember why I did it this way, but "await" here is much better, especially now that the function has some nested logic. Change is pretty easy and only in 5 places so I did it just now.
/** Callback invoked once all listeners to a Query are removed. */ | ||
private onUnlisten?: (query: Query) => Promise<void>; | ||
|
||
subscribe( |
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.
The name "subscribe" is confusing in this context because this doesn't actually subscribe, it only registers callbacks. Also have listen operations so it's potentially confusing to have to choose between listen and subscribe.
Meanwhile, in other contexts where we're wiring up these kinds of things on demand there is no separate function: we're just assigning to the members to set them. Rather than renaming this to something like "registerHandlers" or something like that, why not remove this function and assign directly?
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.
That works for me. I changed it to assign directly. The code size change will be part of my next perf :)
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.
Update: I had to revert this since this breaks the "terminate()" tests. The call to get the component provider throws an exception if the client has already been terminated, and converting these calls to async functions turns that into a rejected Promise.
@@ -277,6 +283,11 @@ export class FirestoreClient { | |||
this.syncEngine = onlineComponentProvider.syncEngine; | |||
this.eventMgr = onlineComponentProvider.eventManager; | |||
|
|||
this.eventMgr.subscribe( |
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.
Doesn't doing this unconditionally here cause this to not be tree-shakeable? I would have thought we'd do this in the listen method.
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.
Because of how FirestoreClient brings together all components, I actually removed FirestoreClient from the firestore-exp build to support the the cache-only build. It will eventually go away altogether, but for now it is still needed for the legacy build.
In the modular build, these functions are only assigned in getEventManager
, which is only called in the listen codepath.
@wilhuff In case you need a distraction :) |
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.
LGTM
@@ -45,6 +45,10 @@ export interface Observer<T> { | |||
* EventManager is responsible for mapping queries to query event emitters. | |||
* It handles "fan-out". -- Identical queries will re-use the same watch on the | |||
* backend. | |||
* | |||
* PORTING NOTE: On Web, EventManager requires a call to `subscribe()` to |
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.
This comment is no longer accurate. Instead, the onListen and onUnlisten handlers need to be assigned.
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.
Updated
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 approve the semicolon change in rules-unit-testing
;
Ship it;
No; I did not review anything else;
This PR changes EventManager and SyncEngine to make listen/unlisten and the related View-processing logic optional. This is a stepping stone that will allow us to remove any Watch-related code for users that don't use Watch (or let them lazy load the components).
View processing logic is "injected" via a new
docsChangeListener
in SyncEngine that is provided when the first view is initialized: https://github.com/firebase/firebase-js-sdk/pull/3561/files#diff-50ed4637ed85c4c9895df1ce8112114aR867I personally think this is pretty ugly, but I couldn't come up with a cleaner design. Suggestions welcome :)