Skip to content

narrow: Encapsulate the Narrow type within its module, checked with an opaque type#4342

Merged
chrisbobbe merged 15 commits intozulip:masterfrom
gnprice:pr-narrow-opaque
Dec 17, 2020
Merged

narrow: Encapsulate the Narrow type within its module, checked with an opaque type#4342
chrisbobbe merged 15 commits intozulip:masterfrom
gnprice:pr-narrow-opaque

Conversation

@gnprice
Copy link
Copy Markdown
Member

@gnprice gnprice commented Dec 16, 2020

In this series, we take all the places where our code used to depend on the details of our internal representation of narrows, and convert them so they use reasonable explicit interfaces provided by the narrow module. Along the way we introduce several new such interfaces that we hadn't had before, and simplify some of the calling code.

At the end of the series, we recruit Flow to confirm for us that the conversion is complete, by turning Narrow into an opaque type (aliasing its real internal representation) so that code outside the module cannot create narrows or see inside them.

This follows on the recent previous PRs #4339, #4335, #4330, and #4329, and sets us up to be able to address #4333, changing the internal representation of narrows so that it identifies users and streams by their numeric IDs, rather than emails and stream names. I have a draft branch on top of this one which does the user part of that migration.

Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks, @gnprice! See a few comments, below.

Comment thread src/utils/narrow.js Outdated
export const emailOfPm1to1Narrow = (narrow: Narrow): string =>
caseNarrowPartial(narrow, {
pm: emails => {
invariant(emails.length === 1, 'emailOfPm1to1Narrow: got 1:1 narrow');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is that quite the right text for the second argument, the error message when the invariant is violated? IIUC, "got 1:1 narrow" describes the correct thing happening for a 1 to 1 PM narrow, not the wrong thing happening.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ah thanks! Indeed -- will fix.

Comment thread src/utils/narrow.js
* into a data structure;
* * `caseNarrow` and its relatives, for pattern-matching or destructuring;
* * `ApiNarrow` for the form we put a narrow in when talking to the
* server, and `apiNarrowOfNarrow` for converting to it.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

and apiNarrowOfNarrow for converting to it.

Nit: apiNarrowOfNarrow doesn't appear until near the end of this series.

Comment thread src/utils/narrow.js Outdated
});

/**
* The "PM key recipients" for a PM narrow; else error.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The "PM key recipients" for a PM narrow; else error.

Hmm, the part in quotes makes me think of the new PmKeyRecipients type in recipient.js, so much that I might expect it to be the return type, but it's not.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah -- the intention is to refer to that, but it's not the same type because it only has the emails.

This isn't very clear, so I'll add something to the jsdoc to clarify.

Comment thread src/title/TitleStream.js Outdated

render() {
const { narrow, stream, color } = this.props;
const topic = topicOfNarrow(narrow);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the narrow isn't known to be a topic narrow at this point; it might be a stream narrow, and topicOfNarrow would throw.

...Ah, and confirmed in manual testing. 🙂

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Indeed -- thanks for the catch! Will fix.

Comment thread src/utils/narrow.js
* instead of a Narrow in the first place; or if they do handle other kinds
* of narrows, should be using `caseNarrow`.
*/
export const streamNameOfNarrow = (narrow: Narrow): string =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: What's the pattern for how this and its neighbors are ordered? 🙂 I see three is*Narrows (starting from isHomeNarrow), then these *Of*Narrows...then seven more is*Narrows.

I could see all the is*Narrows being together in one section, and all the *Of*Narrows being together in another section.

Or I could see the related ones being paired/grouped together. For example, emailsOfGroupPmNarrow and emailsOfPmNarrow are about PMs, so they could sit alongside isGroupPmNarrow and is1to1PmNarrow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah. It's gotten a bit chaotic, in part because I've sometimes alternated between those two ways of organizing it as I've added things.

Also in part because I've considered several different ideas over time for what the eventual interface of this module and the Narrow type should be, after all this refactoring, and the bug-fixing it brings out, is complete. Some of these getters (like emailsOfGroupPmNarrow) get deleted already in later parts of my draft narrow-refactoring branch. My current thinking is that once Narrow has a reasonable structure, these getters (*Of*Narrow) and predicates (is*Narrow) will all simplify into trivial property accesses, and we can make the type a normal non-opaque type alias again (or perhaps better: an opaque type with a bound equal to its real underlying type, like PmKeyRecipients, so that outside code still can't create values of the type) and just inline all those getters and predicates at their call sites, and delete them.

So I'm inclined to defer any major reshuffling of the order of them until late in this series of PRs, when we have a firmer idea of what the set of things in the interface ultimately is.

Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe Dec 16, 2020

Choose a reason for hiding this comment

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

My current thinking is that once Narrow has a reasonable structure, these getters (*Of*Narrow) and predicates (is*Narrow) will all simplify into trivial property accesses

That's amazing! 🤯

I wondered if something like that was on the horizon; I'm glad it is.

So I'm inclined to defer any major reshuffling of the order of them until late in this series of PRs, when we have a firmer idea of what the set of things in the interface ultimately is.

Cool, sounds good.

@gnprice
Copy link
Copy Markdown
Member Author

gnprice commented Dec 16, 2020

New revision pushed!

We've been taking the data structure that's used in a couple of spots
in the Zulip API as our internal representation of a narrow throughout
the app.  It's a pretty bad fit for that purpose, and we're going to
change to a more reasonable one.  Lay the groundwork by marking the
distinction between the handful of places where we actually mean the
API-facing narrow type, and the rest where we want our own internal
narrow type.

For the present this is just a type alias, because lots of places in
our code still intimately depend on the details of this
representation.

In the upcoming series of commits, we'll fix those so that they all
use explicit interfaces provided by the `narrow` module, after which
we can switch to an opaque type alias so that Flow confirms that
nothing outside that module still depends on the specifics of the
type.  That will then set us up for future changes that actually
alter the type.
We're going to change our internal representation of narrows soon
to something more reasonable.  The internal representation doesn't
belong in tests anyway, so remove it.
We're going to change the internal representation of narrows soon.
The internal representation isn't really any test's business in the
first place, and it especially was never the business of any of these
tests that aren't of the `narrow` module itself.  Instead, they should
use the proper constructors and constants from the `narrow` module.
Instead of treating these items as zero when summing up, filter them
out in the first place.

As a bonus, this makes this case more parallel to the `isTopicNarrow`
case just below.
This deduplicates a bit of code between these two very similar
codepaths.
Use `caseNarrow` to let the specifics of narrow "operands", etc.,
stay an implementation detail of the `Narrow` type.

This also gives us type-checking that we've thought of all the types
of narrows here... and exposes that we had not in fact thought of the
all-PMs narrow!  That's purely a latent bug: despite its general name,
this selector is used only to feed one part of the UI, namely the
"N unreads" banner above a message list, and we don't expose the
all-PMs narrow as a message list you can actually view.  But it's good
to make explicit so we aren't surprised in the future.

The diff is best read with `git diff -b`, as it causes some re-indents.
Instead of using `narrow[0].operand` and hoping the code correctly
lines up its meaning with the conditionals, use the `caseNarrow`
family to unpack the components of the narrow in a structured way.
As the jsdoc added here says, all these call sites should probably be
doing something else: where the code is really about a stream, or a
stream conversation, it should be getting passed down a stream name,
or stream name and topic (better yet, a stream *ID* and possibly a
topic) in the first place, rather than a Narrow.  Where on the other
hand the code naturally takes a whole Narrow because it handles other
kinds of narrows too, it should generally be using caseNarrow.

Fixing that would be its own set of refactors in a mostly
independent direction from this one, though, so for now we leave
that for a future project (or really series of small projects.)
Instead, use the getters and predicates that make up the `narrow`
module's actual interface.
This conversion is a no-op.  But we'll shortly be converting `Narrow`
into an opaque type -- which will make it a no-op that only the
`narrow` module can perform.  That means Flow will check for us that
we've explicitly marked all the places where we intend this
conversion to happen; these are those places.
In the preceding series of commits, we've taken all the places where
our code used to depend on the details of our internal representation
of narrows, and converted them so they use reasonable explicit
interfaces provided by the `narrow` module.

This commit recruits Flow to confirm for us that that job is complete.
Because `Narrow` is now an opaque type, code outside this module now
has to treat it as if it could mean anything: so it can't create a
value of this type any more than it could of type `empty`, and can't
consume one or break it down any more than it could a value of type
`mixed`, the supertype of all types.

Effectively this means that code outside `narrow` can only create a
`Narrow` by invoking something inside the module; can then pass it
around and store it however it pleases; but then can only inspect the
information inside it by passing it back to something within the
module, or to an operation like `===` or `JSON.stringify` that's
completely generic so that it actually accepts type `mixed` as input.

(The way this series was developed is that this commit came first,
immediately after distinguishing the `ApiNarrow` type and making
`Narrow` an alias of it in the first place.  That produced a long
list of Flow errors, which served as a to-do list to make the other
changes in the series.  Once complete, this commit was rebased to the
end so that, as always, no commit in the series breaks our tests.)
@chrisbobbe
Copy link
Copy Markdown
Contributor

Great! Thanks again; merged.

@chrisbobbe chrisbobbe merged commit 1ca184d into zulip:master Dec 17, 2020
@gnprice gnprice deleted the pr-narrow-opaque branch December 18, 2020 07:43
gnprice added a commit to gnprice/zulip-mobile that referenced this pull request Jan 6, 2021
This completes a major objective of the long string of refactoring
that appeared in the series of PRs zulip#4330, zulip#4332, zulip#4335, zulip#4339, zulip#4342,
then zulip#4346, zulip#4356, zulip#4361, zulip#4364, and most recently zulip#4368.  After this
change, the portion of zulip#4333 that's about PMs, emails, and user IDs --
aka the portion of zulip#3764 that's about narrows -- is complete.

Still open from zulip#4333 is to convert stream and topic narrows from
stream names to stream IDs.  I'm hopeful that that will be simpler:
(a) unlike the situation for PMs, there's just one stream mentioned
at a time, so there's no question of sorting, and (b) there isn't
the "include self or not" complication that's bedeviled much of our
code related to PMs.  In other words, stream and topic narrows
don't suffer from zulip#4035.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants