-
Notifications
You must be signed in to change notification settings - Fork 7
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
UIAPPS-104 Create a UI Extensions react
package
#94
UIAPPS-104 Create a UI Extensions react
package
#94
Conversation
We want all `@datadog/ui-extensions-` packages to have the same version. This is explained in more detail in the `RELEASE.md` file. The long and short is that it should be easier on App Developers if they don't have to manage different sets of versions, and rather can just install one version for everything. We add a script that checks that all the `@datadog/ui-extensions-*` packages have a changeset if any of them do. This script works in tandem with the change to `.changeset/config.json` where we link all `@datadog/ui-extensions-*` packages together. It's a bit unfortunate that we have to roll a script for this, but it's not the end of the world. If changesets gets this behavior, we can move over to using that instead of this two-pronged approach. Now, we can start adding packages.
We want to provide helpers for using the SDK with `react`. We throw together a single hook for now: `useContext`. It provides the context along with its state. We also give it a few tests to check that it behaves the way we expect it to. As far as the dependencies, we have two peer dependencies: `react`, and `@datadog/ui-extensions-sdk`. Since both of these packages are being used by our own package, and we also depend on their APIs, we want to make sure the consumer of our package have these others installed. This is a bit different from how dependencies normally work in the `npm` world (but fairly normal to how dependencies work in other ecosystems). This pattern tends to show up for similar packages to what we have here: other `react`-based packages, plugins, or (more generally) anything where the public API depends on a different package's API. We also add a changeset for this new package so it'll be released.
We want to start dogfooding this package. There's two big reasons to dogfood this: 1. It provides examples of how to actually use it. 2. It gives us feedback on whether the juice is worth the squeeze. The first point is one that shouldn't be understated. For stuff like this that can be kind of complex, having an example of how it's supposed to work can be really helpful. There's a time and place for reference documentation, and there's a time and place for example documentation. The second point is more of an internal metric. If we look at what is provided and don't see anything worth maintaining, we can more easily justify dropping it. If we do see big benefits, we can more easily justify maintaining or expanding it. In any case, most of these examples are relatively straight-forward. The thing that's kind of interesting is that in some cases we don't need to use `react` hooks to get similar behavior anymore. We dropped a bunch of `useEffect`s and even got rid of some `useState`s. Hopefully as we expand on this `react` package, we can continue getting this sorts of benefits.
We don't want to force a major bump unnecessarily. For our use case, we don't want the packages to bump a major version. We should ask upstream what the situation is and if there's a way to get the behavior we want.
client.getContext().then(c => { | ||
setMetric(c.widget?.definition.options?.metric); | ||
}); | ||
if (result.type === 'initialized') { |
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.
❓ It kind of feels like we should take this opportunity to show some decent patterns for dealing with the other two states: initializing, and handshake failure. I didn't want to make too big of a change in case that wasn't wanted. What do y'all think?
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 funny thing is that when the frame is in loading or error state, the iframe is invisible so it almost doesn't matter what the app does, short of blowing up. The right pattern is honestly probably just
if result.type === 'initialized' {
return null
}
// skip all other logic
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.
Oh right! Makes me wish we didn't have to do something about those other states and could just return the context only.
Maybe what would be more useful for actually rendering a component is not the hook for context, but a component that passes down the context once it's ready and only renders once it has a context. So you'd do something like:
import { WithContext } from '@datadog/ui-extensions-react';
import { Context, init } from '@datadog/ui-extensions-sdk';
type WidgetProps = {
context: Context;
};
function Widget(props: WidgetProps) {
// Work with the actual `Context` in `props.context`.
…
}
export default function render() {
ReactDOM.render(
<React.StrictMode>
<WithContext client={client}>
<Widget />
</WithContext>
</React.StrictMode>,
document.getElementById('root')
);
}
Or something, general idea being that the hook isn't as helpful as it could be since it's still too low-level.
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.
Or maybe, we just don't deal with the error at all and the return type of useContext
becomes Context | undefined
?
"@datadog/ui-extensions-sdk": "0.25.0", | ||
"react": "^17.0.2" |
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.
These are peer dependencies because we depend on their APIs for this package's own APIs.
filename: 'ui-extensions-react.min.js', | ||
path: path.resolve(__dirname, 'dist'), | ||
library: 'DD_REACT', | ||
libraryTarget: 'umd' |
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 was mostly lifted from the SDK config. Does the global (DD_REACT
) make sense here?
+ // Don't force a major bump if the dependent is a peer deependency and it's an exact version. | ||
+ if (depType === "peerDependencies" && versionRange === nextRelease.oldVersion) { | ||
+ return false; | ||
+ } |
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.
Just noting that this is explained more fully in the patches/README.md
file.
This was a copy/paste from the SDK package.
We don't want to include these dependencies in our bundle. The purpose of this package is to provide additional help atop these other two packages. We don't want to also bring them in with us for a few reasons: 1. The `@datadog/ui-extensions-react` package has these as peer dependencies. If we're bundling our peer dependencies, it defeats the purpose. 2. If we're bundling the `@datadog/ui-extensions-sdk`, we can run into issues. E.g. the `init` function will not work correctly if someone has both `@datadog/ui-extensions-react` and `@datadog/ui-extensions-sdk` installed and uses them both. We tell `webpack` that these are external dependencies and how to load them (by their same package name).
We had an infinite loop before. The previous code was a mistake, we accidentally were calling `setMetric` in a way that forced us to continue calling `setMetric`. To alleviate that, we move the initial metric value up above the call to `useState`, and use it there. Likely, we should take this opportunity to provide better handling of the different states the context can be in. If we're still initializing, we can provide a different interface that if the handshake failed, and the normal interface when things are initialized.
…104/create-react-package-for-ui-extensions
This should slot it in with the other docs.
We make a quick hack to get things working again with `yarn workspaces`. The issue with the `yarn workspaces` command is that it takes whatever it finds in the `"workspaces"` field and uses that in the order it's written. It does not look at any of the dependencies of the packages referenced and sort them topologically. Because we finally added a package that doesn't happen to have the same ordering lexicographically as it does topologically, all of the `yarn workspaces` command were failing. We first tried to use `lerna` for its topological sorting, but it both only looks at `dependencies` (not `peerDependencies` or even `devDependencies`) and also was ignoring all of the `examples` packages. We considered bringing in another tool to help with this, but found out that the `yarn workspaces` command will respect the ordering of the `"workspaces"` field in the manifest. We do a semi-topological sort on the packages, and let `yarn workspaces` take care of the rest. We should circle back to this at some point, but this should be enough to get us unblocked from being completely broken.
We want people to have an easier time getting started.
RELEASE.md
Outdated
The alternative would be to allow different versions for each package. | ||
That allows more flexibility and lower version churn at the expense of confusion with version updates. | ||
A similar example to the above might be worded like, | ||
"Check out this wonderful feature in 0.36.0 of `@datadog/ui-extensions-sdk`, 2.3.0 of `@datadog/ui-extensions-react`, 1.46.0 of `@datadog/ui-extensions-vue`, etc." | ||
While that's a model that many other groups of packages follow, | ||
we're opting for keeping all the versions the same. |
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.
🔊
.changeset/config.json
Outdated
@@ -5,6 +5,6 @@ | |||
"changelog": "@changesets/cli/changelog", | |||
"commit": false, | |||
"ignore": ["datadog-app-example-*"], | |||
"linked": [], | |||
"linked": [["@datadog/ui-extensions-*"]], |
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.
cool!
type Context = | ||
| ContextHandshakeFailure | ||
| ContextInitialized | ||
| ContextInitializing; | ||
|
||
/** | ||
* There was some kind of failure with the handshake. | ||
* There's no useful {@link uiExtensionsSDK.Context}, but there is an error. | ||
*/ | ||
type ContextHandshakeFailure = { type: 'handshake failure'; error: unknown }; | ||
|
||
/** | ||
* The handshake is still being preformed. | ||
* There's no useful {@link uiExtensionsSDK.Context} yet. | ||
*/ | ||
type ContextInitializing = { type: 'initializing' }; | ||
|
||
/** | ||
* The handshake has succeeded and there is a {@link uiExtensionsSDK.Context} that can be used. | ||
*/ | ||
type ContextInitialized = { | ||
type: 'initialized'; | ||
context: uiExtensionsSDK.Context; | ||
}; |
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 have an alternative schema proposal: Many react developers are familiar with hook patterns for async operations that look like what you end up using from useQuery
:
const { data, isLoading, error } = `useQuery(...)`;
Rather than using a discriminated union, we could try to mimix that pattern:
interface useContextResult {
isLoading: boolean;
context: uiExtensionsSDK.Context | null;
error: Error | null
}
const { isLoading, error, context } = useContext(client);
What do you think? Of course it's the same information in the end, just attempting to follow existing patterns more closely
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.
tl;dr; I'm okay with whatever here. I'll update this next week unless the longer response compels you otherwise. You don't have to read the rest of this, but can if you'd like.
I have a more thoughts about this, but this response ended up being long enough as it is. If you'd like me to go into even more detail lemme know.
Whatever the API of this ends up being, there's maybe a more important point to make. I don't see this particular hook being that useful for App developers in their day-to-day work, but very useful for IDX. There's at least two reasons why I say this:
- The SDK
Context
mostly has optional data, and trying to drill down on that seems harder than it needs to be. - I think most App developers would rather use something else aside from
Context
.
The second point I think is where we're going to get the most benefit out of having a package like this. I was playing around with a useCustomWidgetOptions
hook that built on top of this useContext
hook. It seemed to remove the majority of the explicit references to the Context
. I feel like it'd be similar for most other hooks we'd build: useTimeframe
, useColorTheme
, etc. All of these have decent defaults and can plaster over the different states of useContext
no matter what its API looks like.
Context
gives a lot of data, and it can be useful to solve a lot of problems we run into. For sure we should make it available to App developers can unblock themselves if we don't provide an easy thing to use. But, it feels more like a low-level primitive than something we expect people to work with in day-to-day. And we should try to capture those patterns that people do instead. I didn't want to add that to the documentation because there's nothing else to point people to at the moment.
To that end, I feel like we should optimize correctness over familiarity. In this situation, familiarity makes it harder to know you're doing the right thing when using useContext
. There's eight different states to deal with:
isLoading |
context |
error |
---|---|---|
false |
null |
null |
false |
null |
Error |
false |
Context |
null |
false |
Context |
Error |
true |
null |
null |
true |
null |
Error |
true |
Context |
null |
true |
Context |
Error |
Whereas with the discriminated union there's only three actual states:
isLoading |
context |
error |
Actual states |
---|---|---|---|
false |
null |
null |
"initializing" |
false |
null |
Error |
|
false |
Context |
null |
|
false |
Context |
Error |
|
true |
null |
null |
|
true |
null |
Error |
"handshake failure" |
true |
Context |
null |
"initialized" |
true |
Context |
Error |
For something that seems like it'll end up being more internal than external, it seems like we should optimize for simplifying on our end.
Of course, if my read on useContext
and the SDK Context
in general is off, then yeah we should optimize for App developer familiarity.
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.
A summary of in-person discussions:
- I like the pattern of this hook returning
Context | null
to start with - If we need extra hooks for handling handshake errors on the developer's end, we can do so later and explicitly
- I like that we have a data model with shape, it makes stuff discoverable. The data model will come to be more and more complex over time. Ideally there is a way to both have a general and explorable data model and specificity for common usecases in a way that feels layered rather than disjoint. One idea discussed was a context selector pattern:
useContextSelector(getDashboardTimeframe)
. - I like the idea of automatically calling
init
inside this hook or another wrapper. The one usecase it won't cover is defining an auth provider, but this is generally done outside react at present. Later we may move to a setProvider() model anyways
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 tried to do the fourth thing on the list, calling init
within the hook. But I can't get the tests to pass in that case. Leads me to one of two ideas: either we need to mock things differently (most likely), or this might be a bug in jest
that's fixed in a newer version (less likely).
In either case, I'm going to punt on it until we can add some tests for that behavior that pass.
); | ||
|
||
return () => { | ||
// We flip the flag to let any in-flight promises know we're unmounted. |
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.
Good catch, I probably would have forgotten to do this
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.
Credit goes to the testing package. It pointed out so many failures and misunderstandings while trying to build this hook. It's way more complex than I'd thought!
`useState` doesn't work the way it seems! This seems to always bite my (joneshf-dd) whenever I use `useState`. It always seems like you can pass in a value and it'll work properly, but it really doesn't. The value that's passed in is only used on initial render and never again. So in this case, we were never setting the metric once the context was initialized. We move the setting into the `useEffect` hook so it actually works properly.
Missed this examples when doing the initial pass. These are again showing the benefit to having this package. We don't have to place a huge burden on App developers to understand how to work with the `Context`. Instead, the hook can take care of most of the intricacies and then using it is fairly straight-forward.
…e-for-ui-extensions
We don't want App developers to worry about the other two states. Through discussing the previous API of `useContext`, we decided to return an optional `Context`. The main factor in this is that when the App is actually used in the Datadog platform, it'll never actually be rendered unless the `Context` exists. If there's not a `Context` available yet (because the handshake hasn't finished), we don't render the App at all. If there's a failure in the handshake, we also don't render the App at all. From the perspective of the App developer, it's a bit of a mis-step to attempt to render something if there's no `Context`. Rather than supporting that pattern, we combine all the non-existent states together into `undefined`. There's still more we can do here, like not rendering the component at all if it doesn't have a context. But, we hold off on any of that until later.
Maybe the react api patterns being debated here can be an engineering sync topic? |
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 think this is ready to merge, press the button! I'm really excited about this getting out, i will use it!
We should poll the team about whether they would prefer a useContext
hook or a component wrapper, but my take is that having both is a good thing depending on user preference, so this work is valuable anyways
Thanks for the multiple reviews and helping figure out things! |
Motivation
react
.Changes
There's a lot here, but it's mostly infrastructure changes.
@datadog/ui-extensions-
gets locked together with the same version. This took some extra work to makechangesets
still work the way we want. Hopefully if Add support forfixed
packages changesets/changesets#690 merges, it'll mean we can remove a lot of this behavior.There's also a patch that we had to do to the
@changesets/assemble-release-plan
package that we should ask about.Also, we should be able to build upon it to make other aspects of UI Extensions easier as well.
Testing
Releases
Choose one:
If you're only updating examples/documentation, this is likely what you want.
📦 Published PR as canary version:
Canary Versions
✨ Test out this PR locally via: