Fetch things sooner on the dashboard

closes: CORE-2904 CORE-2905 CORE-2906

This will send the XHR requests for the dashcards and for all the stuff
in the planner dashboard sooner. As in, as soon as the html document 
comes back from the server, instead of waiting until all the js bundles
have downloaded and executed. This should remove a round-trip from
the dashboard page and you should notice the planner and dashcard
dashboards complete loading a lot faster. By sending the requests
sooner, our app servers can work on them while the browser is 
downloading all the rest of the JS that it needs to run the page. And 
by having our app servers work on that at the same time the browser 
works on the JS, the entire page will complete loading faster.

Test plan:
* go to the planner dashboard
* you should see 4 requests with the type “fetch” in the network panel
  in the waterfall right at the same time the html document is returned
* you should not see any more XHRs sent from axios on page load for the
  planner dashboard (it should use the prefetched ones)
* in a prod environment, where you have multiple app server processes
  handling requests, the planner dashboard should complete loading a lot
  faster

* In that same prod like environment, go to the dashcard dashboard
* same thing, it should issue a “fetch” request right with the html
  document comes back from the server and not issue an second
  XHR once the JS is loaded.
* it should be complete loading significantly faster too.


Change-Id: I0503c1a4d913fd1baa4dad22b9a88333ff747c0d
Reviewed-on: https://gerrit.instructure.com/192161
Tested-by: Jenkins
QA-Review: Mysti Lilla <mysti@instructure.com>
Product-Review: Mysti Lilla <mysti@instructure.com>
Reviewed-by: Brent Burgoyne <bburgoyne@instructure.com>
This commit is contained in:
Ryan Shaw 2019-05-04 00:59:37 -06:00
parent fe3f0b4e6e
commit 50cb8d8388
17 changed files with 154 additions and 72 deletions

4
.gitignore vendored
View File

@ -61,8 +61,8 @@ docker-compose.local.*
# sub-packages
/packages/*/node_modules
/packages/canvas-planner/lib
/packages/canvas-planner/es
/packages/*/lib
/packages/*/es
/packages/canvas-planner/coverage/
/packages/canvas-planner/.babel-cache

View File

@ -623,7 +623,7 @@ class UsersController < ApplicationController
end
end
render :layout => false
render :formats => 'html', :layout => false
end
def toggle_hide_dashcard_color_overlays

View File

@ -1209,4 +1209,14 @@ module ApplicationHelper
# stub
end
def prefetch_xhr(url, id: nil, options: {})
id ||= url
opts = {
credentials: 'same-origin',
headers: {
Accept: 'application/json+canvas-string-ids, application/json'
}
}.deep_merge(options)
javascript_tag "(window.prefetched_xhrs = (window.prefetched_xhrs || {}))[#{id.to_json}] = fetch(#{url.to_json}, #{opts.to_json})"
end
end

View File

@ -20,6 +20,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import getDroppableDashboardCardBox from '../dashboard_card/getDroppableDashboardCardBox'
import axios from 'axios'
import {asJson, getPrefetchedXHR} from '@instructure/js-utils'
let promiseToGetDashboardCards
@ -45,7 +46,9 @@ export default function loadCardDashboard() {
if (cachedCards) render(JSON.parse(cachedCards))
if (!promiseToGetDashboardCards) {
promiseToGetDashboardCards = axios.get('/api/v1/dashboard/dashboard_cards').then(({data}) => data)
const url = '/api/v1/dashboard/dashboard_cards'
promiseToGetDashboardCards = asJson(getPrefetchedXHR(url)) || axios.get(url).then(({data}) => data)
promiseToGetDashboardCards.then(dashboardCards =>
sessionStorage.setItem(sessionStorageKey, JSON.stringify(dashboardCards))
)

View File

@ -28,6 +28,7 @@ import apiUserContent from 'compiled/str/apiUserContent'
import DashboardOptionsMenu from '../dashboard_card/DashboardOptionsMenu';
import loadCardDashboard from '../bundles/dashboard_card'
import $ from 'jquery'
import {asText, getPrefetchedXHR} from '@instructure/js-utils'
import 'jquery.disableWhileLoading'
const [show, hide] = ['block', 'none'].map(displayVal => id => {
@ -221,7 +222,8 @@ function showTodoList () {
if (ENV.DASHBOARD_SIDEBAR_URL) {
const rightSide = $('#right-side')
const promiseToGetNewCourseForm = import('compiled/util/newCourseForm')
const promiseToGetHtml = $.get(ENV.DASHBOARD_SIDEBAR_URL)
const promiseToGetHtml = asText(getPrefetchedXHR(ENV.DASHBOARD_SIDEBAR_URL)) || $.get(ENV.DASHBOARD_SIDEBAR_URL)
rightSide.disableWhileLoading(
Promise.all([promiseToGetNewCourseForm, promiseToGetHtml]).then(([{default: newCourseForm}, html]) => {
// inject the erb html we got from the server

View File

@ -17,32 +17,14 @@
*/
import axios from 'axios'
// In the the index.html.erb view for this page, we fire of `fetch` requests for all the
// get requests for all the discusisons we're going to render. We do this so they
// can start loading then and not have to wait until this JS file is loaded to start
// fetching. But since they are just raw `fetch` responses, we need to massage them
// into something that looks like an axios response, since that is what everything
// here is designed to deal with
let axiosResponses
function getFetchRequests() {
return (
axiosResponses ||
(axiosResponses = (window.preloadedDiscussionTopicFetchRequests || []).map(fetchRequest => {
return fetchRequest.then(res => {
return res.json().then(json => {
return {
data: json,
headers: {link: res.headers.get('Link')}
}
})
})
}))
)
}
import {asAxios, getPrefetchedXHR} from '@instructure/js-utils'
export function getDiscussions({contextType, contextId}, {page}) {
return getFetchRequests()[page - 1]
// In the the index.html.erb view for this page, we use prefetch_xhr to fire off
// `fetch` requests for all the discusisons we're going to render. We do this
// so they can start loading then and not have to wait until this JS file is
// loaded to start fetching.
return asAxios(getPrefetchedXHR(`prefetched_discussion_topic_page_${page - 1}`))
}
export function updateDiscussion({contextType, contextId}, discussion, updatedFields) {

View File

@ -24,19 +24,10 @@
js_bundle :discussion_topics_index_v2
css_bundle :discussions_index
%>
<% content_for :stylesheets do # we put this in :stylesheets so it is rendered early, in the <head> %>
<script>
preloadedDiscussionTopicFetchRequests = <%= raw(@discussion_topics_urls_to_prefetch.to_json) %>
.map(function(url) {
return fetch(url, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json+canvas-string-ids, application/json'
}
})
});
</script>
<% @discussion_topics_urls_to_prefetch.each_with_index do |url, i| %>
<%= prefetch_xhr(url, id: "prefetched_discussion_topic_page_#{i}") %>
<% end %>
<% end %>

View File

@ -22,9 +22,15 @@ default_number_of_fake_dashcards_to_show = 5
number_of_fake_cards_to_show =
Rails.cache.read(['last_known_dashboard_cards_count', @current_user.global_id].cache_key) ||
default_number_of_fake_dashcards_to_show
%>
<div id="DashboardCard_Container" style="display: <%= user_dashboard_view == 'cards' ? 'block' : 'none' %>">
render_on_pageload = user_dashboard_view == 'cards'
%>
<% if render_on_pageload %>
<%# fire off these `fetch` requests now so they are ready sooner %>
<%= prefetch_xhr('/api/v1/dashboard/dashboard_cards') %>
<%= prefetch_xhr(dashboard_sidebar_url) %>
<% end %>
<div id="DashboardCard_Container" style="display: <%= render_on_pageload ? 'block' : 'none' %>">
<div class="ic-DashboardCard__box">
<% for i in 1..number_of_fake_cards_to_show do %>
<div class="ic-DashboardCard">

View File

@ -17,9 +17,22 @@
%>
<% content_for :page_title do %><%= t('Dashboard') %><% end %>
<% css_bundle :dashboard %>
<% js_bundle :dashboard %>
<% @body_classes << "dashboard-is-planner" if show_planner? %>
<%
css_bundle :dashboard
js_bundle :dashboard
if show_planner?
@body_classes << "dashboard-is-planner"
# fire off these `fetch` requests now so they are ready sooner
six_months_ago = Time.now.utc.at_beginning_of_day.months_ago(6).iso8601(3)
beginning_of_day = Time.zone.now.at_beginning_of_day.utc.iso8601(3)
%>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{six_months_ago}&filter=new_activity&order=asc") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{beginning_of_day}&order=desc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_day}") %>
<%= prefetch_xhr('/api/v1/users/self/missing_submissions?include[]=planner_overrides&filter[]=submittable') %>
<% end %>
<% content_for :auto_discovery do %>
<% if @current_user %>

View File

@ -70,8 +70,7 @@ describe('api actions', () => {
fromMoment, getState: () => ({loading: {}}),
});
return moxiosWait(request => {
expect(request.config.url).toBe('/api/v1/planner/items');
expect(request.config.params.start_date).toBe(fromMoment.toISOString());
expect(request.config.url).toBe(`/api/v1/planner/items?start_date=${fromMoment.toISOString()}`);
});
});
@ -91,9 +90,7 @@ describe('api actions', () => {
fromMoment, intoThePast: true, getState: () => ({loading: {}}),
});
return moxiosWait(request => {
expect(request.config.url).toBe('/api/v1/planner/items');
expect(request.config.params.end_date).toBe(fromMoment.toISOString());
expect(request.config.params.order).toBe('desc');
expect(request.config.url).toBe(`/api/v1/planner/items?end_date=${fromMoment.toISOString()}&order=desc`);
});
});
@ -144,9 +141,7 @@ describe('api actions', () => {
const mockMoment = moment.tz('Asia/Tokyo').startOf('day');
Actions.getFirstNewActivityDate(mockMoment)(mockDispatch, getBasicState);
return moxiosWait(request => {
expect(request.config.params.filter).toBe('new_activity');
expect(request.config.params.start_date).toBe(mockMoment.subtract(6, 'months').toISOString());
expect(request.config.params.order).toBe('asc');
expect(request.url).toBe(`/api/v1/planner/items?start_date=${mockMoment.subtract(6, 'months').toISOString()}&filter=new_activity&order=asc`);
});
});

View File

@ -17,6 +17,7 @@
*/
import { createActions, createAction } from 'redux-actions';
import axios from 'axios';
import {asAxios, getPrefetchedXHR} from '@instructure/js-utils';
import configureAxios from '../utilities/configureAxios';
import { alert } from '../utilities/alertUtils';
import formatMessage from '../format-message';
@ -110,15 +111,12 @@ export const getInitialOpportunities = () => {
return (dispatch, getState) => {
dispatch(startLoadingOpportunities());
axios({
method: 'get',
url: getState().opportunities.nextUrl || '/api/v1/users/self/missing_submissions?include[]=planner_overrides&filter[]=submittable',
}).then(response => {
if(parseLinkHeader(response.headers.link).next) {
dispatch(addOpportunities({items: response.data, nextUrl: parseLinkHeader(response.headers.link).next.url }));
}else {
dispatch(addOpportunities({items: response.data, nextUrl: null}));
}
const url = getState().opportunities.nextUrl || '/api/v1/users/self/missing_submissions?include[]=planner_overrides&filter[]=submittable'
const request = asAxios(getPrefetchedXHR(url)) || axios({method: 'get', url})
request.then(response => {
const next = parseLinkHeader(response.headers.link).next;
dispatch(addOpportunities({items: response.data, nextUrl: (next ? next.url : null)}));
}).catch(() => alert(formatMessage('Failed to load opportunities'), true));
};
};

View File

@ -18,6 +18,8 @@
import { createActions, createAction } from 'redux-actions';
import axios from 'axios';
import buildURL from 'axios/lib/helpers/buildURL.js';
import {asAxios, getPrefetchedXHR} from '@instructure/js-utils';
import { transformApiToInternalItem } from '../utilities/apiUtils';
import { alert } from '../utilities/alertUtils';
import formatMessage from '../format-message';
@ -84,11 +86,11 @@ export function getFirstNewActivityDate (fromMoment) {
// specifically so we know what the very oldest new activity is
return (dispatch, getState) => {
fromMoment = fromMoment.clone().subtract(6, 'months');
return axios.get('/api/v1/planner/items', { params: {
start_date: fromMoment.toISOString(),
filter: 'new_activity',
order: 'asc'
}}).then(response => {
const url = `/api/v1/planner/items?start_date=${fromMoment.toISOString()}&filter=new_activity&order=asc`
const request = asAxios(getPrefetchedXHR(url)) || axios.get(url)
return request.then(response => {
if (response.data.length) {
const first = transformApiToInternalItem(response.data[0], getState().courses, getState().groups, getState().timeZone);
dispatch(foundFirstNewActivityDate(first.dateBucketMoment));
@ -156,10 +158,11 @@ export const loadPastUntilToday = () => (dispatch, getState) => {
export function sendFetchRequest (loadingOptions) {
return axios.get(...fetchParams(loadingOptions))
.then(response => handleFetchResponse(loadingOptions, response))
// no .catch: it's up to the sagas to handle errors
;
const [urlPrefix, {params}] = fetchParams(loadingOptions)
const url = buildURL(urlPrefix, params)
const request = asAxios(getPrefetchedXHR(url)) || axios.get(url)
return request.then(response => handleFetchResponse(loadingOptions, response))
// no .catch: it's up to the sagas to handle errors
}
function fetchParams (loadingOptions) {

View File

@ -0,0 +1,22 @@
{
"name": "@instructure/js-utils",
"version": "1.0.0",
"description": "A collection of utilities/helpers used by projects in the canvas/edu/instructure ecosystem.",
"author": "Instructure, Inc.",
"main": "lib/index.js",
"module": "es/index.js",
"sideEffects": false,
"scripts": {
"test": "echo 'no tests yet...'",
"build:canvas": "babel --root-mode upward src -d es & JEST_WORKER_ID=true babel --root-mode upward src -d lib"
},
"repository": {
"type": "git",
"url": "git+https://github.com/instructure/canvas-lms.git"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/instructure/canvas-lms/issues"
},
"homepage": "https://github.com/instructure/canvas-lms#readme"
}

View File

@ -0,0 +1 @@
export * from './prefetched_xhrs'

View File

@ -0,0 +1,43 @@
// These are helpful methods you can use along side the ruby ApplicationHelper::prefetch_xhr helper method in canvas
export function getPrefetchedXHR(id){
return window.prefetched_xhrs && window.prefetched_xhrs[id]
}
/**
* Transforms a `fetch` request into something that looks like an `axios` response
* with a `.data` and `.headers` property, so you can pass it to our parseLinkHeaders stuff
*
* @param {Promise<Response>} fetchRequest
* @returns {Promise<import("axios").AxiosResponse>}
*/
export function asAxios(fetchRequest) {
if (!fetchRequest) return
return fetchRequest.then(res =>
res.json().then(data => ({data, headers: {link: res.headers.get('Link')}}))
)
}
/**
* Takes a `fetch` request and returns a promise of the json data of the response
*
* @param {Promise<Response>} fetchRequest
* @returns {Promise<JSON_data>}
*/
export function asJson(fetchRequest) {
if (!fetchRequest) return
return fetchRequest.then(res => res.json())
}
/**
* Takes a `fetch` request and returns a promise of the text of the response
*
* @param {Promise<Response>} fetchRequest
* @returns {Promise<USVString>}
*/
export function asText(fetchRequest) {
if (!fetchRequest) return
return fetchRequest.then(res => res.text())
}

View File

@ -1032,6 +1032,18 @@ describe ApplicationHelper do
end
end
describe "#prefetch_xhr" do
it "inserts a script tag that will have a `fetch` call with the right id, url, and options" do
expect(prefetch_xhr('some_url', id: 'some_id', options: {headers: {"x-some-header": "some-value"}})).to eq(
"<script>
//<![CDATA[
(window.prefetched_xhrs = (window.prefetched_xhrs || {}))[\"some_id\"] = fetch(\"some_url\", {\"credentials\":\"same-origin\",\"headers\":{\"Accept\":\"application/json+canvas-string-ids, application/json\",\"x-some-header\":\"some-value\"}})
//]]>
</script>"
)
end
end
describe "#alt_text_for_login_logo" do
before :each do
@domain_root_account = Account.default

View File

@ -24,6 +24,7 @@ describe "/discussion_topics/index" do
course_with_teacher
view_context(@course, @user)
assign(:body_classes, [])
assign(:discussion_topics_urls_to_prefetch, [])
render "discussion_topics/index"
expect(response).not_to be_nil
end