What are DeepLinks?
To directly quote Wikipedia:
“Deep linking is the use of a hyperlink that links to a specific, generally searchable or indexed, piece of web content on a website (e.g. http://example.com/path/page
), rather than the website's home page (e.g., http://example.com
). The URL contains all the information needed to point to a particular item.”
Why DeepLinks in Dashboard?
There are many user experiences in Cloudflare’s Dashboard that are enhanced by the use of deep linking, such as:
We’re able to direct users from marketing pages directly into the Dashboard so they can interact with new/changed features.
Troubleshooting docs can have clearer, more intently directions. e.g. “Enable SSL encryption here” vs “Log into the Dashboard, choose your account and zone, navigate to the security tab, change SSL encryption level, blah blah blah”.
One of the interesting challenges with deep linking in the Dashboard is that most interesting resources are “locked” behind the context of an account and a zone/domain/website. To illustrate this, look at a tree of possible URL paths into Cloudflare’s Dashboard:
dash.cloudflare.com/ -> root-level resources: login, sign-up, forgot-password, two-factor
dash.cloudflare.com/<accountId>/ -> account-level resources: analytics, workers, domains, stream, billing, audit-log
dash.cloudflare.com/<accountId>/<zoneId> -> zone-level resources: dns, ssl-tls, firewall, speed, caching, page-rules, traffic, etc.
You might notice that in order to deep link to anything more interesting than logging in, a deep linker will need to know a user’s account or zone beforehand. A troubleshooting doc might want to send a user to the Page Rules tab in Dashboard to help a user fix their zone, but the linker doesn’t know what that zone is.
Another highly desired feature was the ability for a deep link to scroll to a particular piece of content on a Dashboard page, making it even easier for users to navigate. Instead of a troubleshooting doc asking a user to fumble around to find a setting, we could helpfully scroll that setting right into view. Now that would be slick!
What do DeepLinks look like in Dashboard?
The solution we came up with involves 3 main parts:
Deep links URLs expose an intuitive schema for dynamic value resolution.
A React component, DeepLink, consolidates routing/resolving deep links.
A React component, ScrollAnchor, encapsulates a simple algorithm which scrolls its content into view when the DOM has “finished loading”.
Just to prove that it works, here’s a GIF of us deep linking to the “TLS 1.3” setting on the security settings page:
It works! I was asked to select one of my several accounts, then our DeepLink routing component was smart enough to know that I have only one zone within that account and auto-filled the rest of the URL path. After the page was fully loaded, we were automatically scrolled to the TLS 1.3 setting. If you’re curious how all of this works and want to jump into the nitty gritty details, read on!
How are DeepLinks exposed?
If you were paying attention to the URL bar in the GIF above, you already know what’s coming. In order to deal with dynamic account/zone resolution, a deep link can use a to query parameter to specify a path into Dashboard. I think it reads quite nicely:
dash.cloudflare.com/?to=/:account/:zone/ssl-tls/edge-certificates
This example is saying that we’d like to link to the “Edge Certificates” section of the “SSL-TLS” product for some account and some zone that a user needs to manually resolve, as you saw above. It’s easy to imagine removing “?to=/” to transform the link URL into the resolved one:
dash.cloudflare.com/<resolvedAccount>/<resolvedZone>/ssl-tls/edge-certificates
The URL-like schema of the to parameter makes it very natural to support different variations such as account-level resources
dash.cloudflare.com/?to=/:account/billing
Or allowing the linker to supply known information
dash.cloudflare.com/?to=/1234567890abcdef/:zone/traffic
This link takes the user to the “Traffic” product tab for some zone inside of account 1234567890abcdef. Indeed, the :account and :zone symbols are placeholders for user-supplied values, but they can be replaced with any permutation of real, known values to speed up resolution time to provide a better UX.
DeepLink routing
These links are parsed and resolved in our top-level routing component, DeepLink. At a high level, this component contains a series of “resolvers” for unknown symbols that need automatic or user-interactive resolution (i.e. :account and :zone). But before we dive in, let’s take a step back and gain appreciation for how cool this component is.
Cloudflare’s Dashboard is a single page React app, which means we use React Router to create routing components that handle what’s rendered on different URLs:
<Switch>
<Route path="/login"><Login /></Route>
<Route path="/sign-up"><Signup /></Route>
...
<AccountRoutes />
</Switch>
When a page is loaded, a lot of things need to happen: API calls need to be made to fetch all the data needed to render a page, like account/user/zone info not cached in the browser. Many components need to be rendered. It turns out that we can improve the UX of many users by blocking React Router to make specific queries to our API instead of rendering an entire page that anecdotally fetches the information we need. For example, there’s no need to render a zone selection page if a user only has one zone, like in our GIF above ☝️.
Resolvers
When a deep link gets parsed and split into parts, the framework iterates over those parts and tries to build a URL string that is later used to redirect users to a specific location in the dashboard.
// to=/:account/:zone/traffic
// parts = [‘:account’, ‘:zone’, ‘traffic’]
for (const part of parts) {
// do something with each part
}
We can build up the dynamic URL by looking at prefixes. If a part starts with “:”, it’s considered a symbol that needs to be resolved. Everything else is a static string that just gets appended.
const resolvedParts: string[] = [];
// parts = [‘:account’, ‘:zone’, ‘traffic’]
for (let part of parts) {
if (part.startsWith(‘:’)) {
// resolve
}
resolvedParts.push(part);
}
const finalUrl = resolvedParts.join(‘/’);
Symbols are handled by functions we call “resolvers”. A resolver is a function that:
Is async.
Has a context parameter.
Always returns a string - the value it resolves to.
In JavaScript, async functions always return a promise. Return values that are not type of Promise are wrapped in a resolved promise implicitly. They also allow “await” to be used in them. The async/await syntax is used for resolvers so they can perform any kind of asynchronous work - such as calling the API, while being able to “pause” JavaScript with “await” until that asynchronous work is done.
Each dynamic symbol has its own resolver. We currently have two resolvers - for account and for zone.
const RESOLVERS: Resolvers = {
account: accountResolver,
zone: zoneResolver
};
const resolvedParts: string[] = [];
// parts = [‘:account’, ‘:zone’, ‘traffic’]
for (let part of parts) {
if (part.startsWith(‘:’)) {
// for :account, accountResolver is awaited and returns “abc123”
// for :zone, zoneResolver is awaited and returns “testsite.io”
part = await RESOLVERS[part.slice(1)];
}
resolvedParts.push(part);
}
const finalUrl = resolvedParts.join(‘/’);
The internal implementation is a little bit more complicated, but this is a rough overview of how our DeepLink works.
Resolver context
We mentioned that each resolver has a context parameter. Context is an object that is passed to resolvers from the DeepLink component and it contains a bunch of handy utilities that give resolvers control over any part of the app. For example, it has access to the Redux store (we use Redux.js in the Dashboard to help us manage the application’s state). It has access to previously resolved values, and to all other parts of the deep link. It also has functions to help with user interactions.
User interactions
In many cases, a resolver is not able to resolve without the user's help. For example, if a user has multiple zones, the resolver working on :zone symbol needs to wait for the user to select a zone.
const zoneResolver: Resolver = async ctx => {
const zones = await fetchZone();
// Just one zone, :zone symbols can be resolved to zone.name without user’s help
if (zones.length === 1) return zones[0].name;
if (zones.length > 1) {
// need user’s help to pick a zone
}
};
We already have a page in the dashboard with a zone list that looks like this.
What we need to do is give the resolver the ability to somehow show this page, and wait for the result of the user's interaction.You might be asking: “But how do we show this page? You just told me that DeepLink blocks the entire page!”That’s true!
We decided to block the React Router to prevent unnecessary API calls and DOM updates while a deep link is resolving. But there is no harm in showing some part of the UI, if needed. To be able to do that, we added two functions to context - unblockRouter and blockRouter. These functions just toggle the state that is gating our Router component.
const zoneResolver: Resolver = async ctx => {
// ...
if (zones.length > 1) {
// delegate to React Router to render the page with zone picker
ctx.unblockRouter();
// need users help to pick a zone
// block the router again
ctx.blockRouter();
}
};
Now, the last piece is to somehow observe user interactions from within the resolver. To be able to do that, we have written a powerful utility.
waitForPageAction
Resolvers are isolated functions that live outside of the application’s components. To be able to observe anything that happens in distant branches of React DOM, we created a function called waitForPageAction
. This function takes two parameters:
1. pageToAwaitActionOn
- URL string pointing to a page we want to await the user's action on. For example, “dash.cloudflare.com/123abc”
2. actionType
- Unique string describing the action. For example, ZONE_SELECTED
.
As you may have guessed, waitForPageAction is an async function. It returns a promise that resolves with action metadata whenever that action happens on the page specified by pageToAwaitActionOn
. The promise rejects when the user navigates away from pageToAwaitActionOn
. Otherwise, it keeps waiting… forever.
This helps us to write a code that is very easy to understand.
const zoneResolver: Resolver = async ctx => {
// ...
if (zones.length > 1) {
// delegate to React Router to render the page with zone picker
ctx.unblockRouter();
// need users help to pick a zone. Wait for ‘ZONE_SELECTED’ action at ‘dash.cloudflare.com/abc123’
// action is an object with metadata about zone. It contains zoneName, which can be used in this resolver to resolve :zone symbol
const action = ctx.waitForPageAction(
‘dash.cloudflare.com/abc123’,
‘ZONE_SELECTED’
);
// block the router again
ctx.blockRouter();
return action.zoneName
}
};
How does waitForPageAction work?
As mentioned above, we use Redux to manage our state. The actionType
parameter is nothing else than a type of Redux action. Whenever a zone is selected, React dispatches a Redux action in an onClick handler.
<ZoneCard onClick={zoneName => { dispatch({type: ‘ZONE_SELECTED’, zoneName}) }} />
Now, how does waitForPageAction
know that ZONE_SELECTED’
has been dispatched? Aren’t we supposed to write a reducer?!
Not really. waitForPageAction
is not changing any state, it’s just an observer that resolves whenever some action, that is dispatched, satisfies a predicate. And Redux has an API to subscribe to any store changes - store.subscribe(listener)
.
The listener will be called any time an action is dispatched, and some part of the state tree may have changed. Unfortunately, the listener does not have access to the currently dispatched action. We can only read the current state.
Solution? Store the action in the Redux store!
Redux actions are just plain objects (mostly), and thus easy to serialize. We added a simple reducer that stores all actions in the Redux state.
export function deepLinkReducer(
state: State = DEFAULT_STATE,
action: AnyAction
){
const nextState = { ... state, lastAction: action };
return nextState;
}
Anytime an action is dispatched, we can read that action’s metadata in store.getState().lastAction
. Now, we have everything we need to finally implement waitForPageAction
.
export function waitForPageAction = (store: Store<DashState>) =>(
pageToAwaitActionOn: string,
actionType: string
) =>
new Promise<AnyAction>((resolve, reject) => {
// Subscribe to redux store
const unsubscribe = store.subscribe(() => {
const state = store.getState();
const currentPage = state.router.location.pathname;
const lastAction = state.lastAction;
if (currentPage !== pageToAwaitActionOn) {
// user navigated away -unsubscribe and reject
unsubscribe();
reject(‘User navigated away’);
} else if (lastAction.type === actionType) {
// Action types match! Unsubscribe and resolve with action object
unsubscribe();
resolve(lastAction);
}
});
});
The listener reads the current state and grabs the currentPage
and lastAction
data. If currentPage
doesn’t match pageToAwaitActionOn
, it means the user navigated away, and there’s no need to continue resolving the deep link - we unsubscribe, and reject the promise. Deep link resolvers are stopped, and React Router unblocked.
Else, if lastAction.type
matches the actionType
parameter, it means the action we are waiting on just happened! Unsubscribe, and resolve the promise with action metadata. The deep link keeps resolving.
That’s it! We also added a similar function - waitForAction
- which does exactly the same thing, but is not restricted to a specific page.
ScrollAnchor component
We implemented a wrapper component ScrollAnchor that will scroll to its wrapped content, making our deep links even more targeted. A client would wrap some content like this:
<ScrollAnchor id=”super-important-setting-card”>
<SuperImportantSettingCard />
</ScrollAnchor>
And then reference it via a typical URL anchor:
dash.cloudflare.com/path/to/content#super-important-setting-card
Now I can hear you saying, “what’s the point? Can’t we get the same behavior with any old ID?”
<div id=”super-important-setting-card”>
<SuperImportantSettingCard />
</div>
We thought so too! But it turns out that there are a few problems that prevent this super simple approach:
The Dashboard’s fixed header
DOM updates after page load
Since the Dashboard contains a fixed header at the top of the page, we can’t simply anchor to any ID, since the content will be scrolled to the top of the browser window behind the header. Fortunately, there’s a simple CSS solution using negative margins:
<div id=”super-important-setting-card” padding-top={headerOffset} margin-top={headerOffset}>
<SuperImportantSettingCard />
</div>
This CSS trick alone would work for a static site with a fixed header, but the Dashboard is very dynamic. We found early on in testing that using a normal HTML ID anchor in a URL would cause the browser to jump to the tag on page load but the DOM would change in response to newly fetched information or re-rendering, and the anchored content would be pushed out of view.
A solution: scroll to the anchored content after the page content is fully loaded, i.e. after all API calls are resolved, spinners removed, content is rendered. Fortunately, there’s a good way to programmatically scroll a browser window: Element.scrollIntoView(). However, there isn’t a good way to tell when the DOM is finished changing, since it can be modified at any time after page load. Let’s consider two possible strategies for determining when to scroll anchored content into view.
Strategy #1: scroll after a fixed duration. If our goal is to make sure we only scroll to content after a page is “fully loaded”, we can simplify the problem by making some assumptions. Namely, we can assume a maximum amount of time it will take a given page to fetch resources from the backend and re-render the DOM. Let’s call this assumed max duration M
milliseconds. We can then easily scroll to some content by running a timeout on page load:
setTimeout(() => scrollTo(htmlId), M)
The problem with this approach is that the DOM might finish updating before or after we scroll. We end up with vertical alignment problems (as the DOM is still settling) or a jarring, unexpected scroll (if we scroll long after the DOM is settled). Both options are bad UX, and in practice it’s difficult to choose a duration constant M that is “just right” for every single page.
Strategy #2: scroll after the DOM has “settled”. If we know that choosing a good duration M
for every page isn’t practical, we should try to come up with an algorithm that can choose a better M
:
Define an arbitrary threshold of DOM “busyness”,
B
milliseconds.On page load, start a timer that will scroll to anchored content after
B
milliseconds.If we observe any changes to the DOM, reset the timer.
Once the timer expires, we know that the DOM hasn’t changed in
B
milliseconds.
By varying our choice of B
, we’re able to have some control over how long we’re willing to wait for a page to “finish loading”. If B is 0 milliseconds, we’ll scroll to the anchored content immediately. If it’s 1000 milliseconds, we’ll wait a full second after any DOM change before scrolling. This algorithm is more resilient than fixed threshold scrolling since it explicitly listens to the DOM, but the chosen threshold is somewhat arbitrary. After some trial and error loading a sample of Dashboard pages, we determined that a 500 millisecond busyness threshold was sufficient to allow all content to load onto a page. Here’s what the implementation looks like:
const SETTLE_THRESHOLD = 500;
const scrollThunk = (observer: MutationObserver) => {
scrollToAnchor(id);
observer.disconnect();
};
let domTimer: number;
const observer = new MutationObserver((_mutationsList, observer) => {
domTimer = resetTimeout(domTimer, scrollTunk, SETTLE_THRESHOLD, observer);
});
observer.observe(document.body, {childList: true, subtree: true});
domTimer = window.setTimeout(scrollThunk, SETTLE_THRESHOLD, observer);
A key assumption is that API calls take roughly the same amount of time to resolve. If most fetches take 250ms to resolve but others take 1500ms, we might see that the DOM hasn’t been changed for a while and think that it’s settled. Who knew there would be so much work involved in scrolling!
Conclusion
There you have it. A fully-featured deep linking solution with an intuitive schema, React Router blocking, autofilling, and scrolling. Thanks for reading.