From 772d1f6994edea62b861b188dd8957d9d9286a68 Mon Sep 17 00:00:00 2001 From: Ryan Shaw Date: Mon, 15 Jul 2019 09:10:51 -0600 Subject: [PATCH] =?UTF-8?q?don=E2=80=99t=20count=20unread=5Fcount=20or=20d?= =?UTF-8?q?ashcard=20indicators=20against=20newRelic=20load=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes: CORE-3187 AKA: Revert "Revert "don’t count unread_count or dashcard indicators against newRelic load time"" This reverts commit d754ff5ed159f8ca0a922b39be4a784f24d88f63. Test plan: * the little badges that show unread discussion counts and stuff on each dashcard should work the same as before * the badge on the global nav that shows how many unread inbox messages You have should work the same as before * if you have newRelic set up, neither should count against page load metrics Change-Id: I3b869e7fb650f40f16f514db9d8b9f558443db5b Reviewed-on: https://gerrit.instructure.com/201202 Tested-by: Jenkins Reviewed-by: Clay Diffrient QA-Review: Ryan Shaw Product-Review: Ryan Shaw --- .../CourseActivitySummaryStore.js | 30 ++++--- .../CourseActivitySummaryStore.test.js | 70 ++++++++++++++++ app/jsx/navigation_header/Navigation.js | 69 +++++++++++----- .../__tests__/Navigation.test.js | 74 +++++++++++++++++ app/views/layouts/_head.html.erb | 2 +- jest.config.js | 2 + jest/jest-setup.js | 2 +- package.json | 2 +- .../CourseActivitySummaryStoreSpec.js | 77 ----------------- .../navigation_header/NavigationHeaderSpec.js | 82 ------------------- yarn.lock | 26 ++++-- 11 files changed, 232 insertions(+), 204 deletions(-) create mode 100644 app/jsx/dashboard_card/__tests__/CourseActivitySummaryStore.test.js create mode 100644 app/jsx/navigation_header/__tests__/Navigation.test.js delete mode 100644 spec/coffeescripts/jsx/dashboard_card/CourseActivitySummaryStoreSpec.js delete mode 100644 spec/javascripts/jsx/navigation_header/NavigationHeaderSpec.js diff --git a/app/jsx/dashboard_card/CourseActivitySummaryStore.js b/app/jsx/dashboard_card/CourseActivitySummaryStore.js index dc7a8ddbe5c..d0f4a88f86d 100644 --- a/app/jsx/dashboard_card/CourseActivitySummaryStore.js +++ b/app/jsx/dashboard_card/CourseActivitySummaryStore.js @@ -16,34 +16,32 @@ * with this program. If not, see . */ -import _ from 'underscore' import createStore from '../shared/helpers/createStore' -import $ from 'jquery' const CourseActivitySummaryStore = createStore({streams: {}}) CourseActivitySummaryStore.getStateForCourse = function(courseId) { - if (_.isUndefined(courseId)) return CourseActivitySummaryStore.getState() + if (typeof courseId === 'undefined') return CourseActivitySummaryStore.getState() - if (_.has(CourseActivitySummaryStore.getState().streams, courseId)) { - return CourseActivitySummaryStore.getState().streams[courseId] - } else { - CourseActivitySummaryStore.getState().streams[courseId] = {} + const {streams} = CourseActivitySummaryStore.getState() + if (!(courseId in streams)) { + streams[courseId] = {} CourseActivitySummaryStore._fetchForCourse(courseId) - return {} } + return streams[courseId] } CourseActivitySummaryStore._fetchForCourse = function(courseId) { - let state - - $.getJSON(`/api/v1/courses/${courseId}/activity_stream/summary`, stream => { - state = CourseActivitySummaryStore.getState() - state.streams[courseId] = { - stream - } - CourseActivitySummaryStore.setState(state) + const fetch = window.fetchIgnoredByNewRelic || window.fetch // don't let this count against us in newRelic's SPA load time stats + fetch(`/api/v1/courses/${courseId}/activity_stream/summary`, { + headers: {Accept: 'application/json'} }) + .then(res => res.json()) + .then(stream => { + const state = CourseActivitySummaryStore.getState() + state.streams[courseId] = {stream} + CourseActivitySummaryStore.setState(state) + }) } export default CourseActivitySummaryStore diff --git a/app/jsx/dashboard_card/__tests__/CourseActivitySummaryStore.test.js b/app/jsx/dashboard_card/__tests__/CourseActivitySummaryStore.test.js new file mode 100644 index 00000000000..712ead16c79 --- /dev/null +++ b/app/jsx/dashboard_card/__tests__/CourseActivitySummaryStore.test.js @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import CourseActivitySummaryStore from '../CourseActivitySummaryStore' +import wait from 'waait' + +describe('CourseActivitySummaryStore', () => { + const stream = [ + { + type: 'DiscussionTopic', + unread_count: 2, + count: 7 + }, + { + type: 'Conversation', + unread_count: 0, + count: 3 + } + ] + beforeEach(() => { + CourseActivitySummaryStore.setState({streams: {}}) + }) + + describe('getStateForCourse', () => { + it('should return root state object when no courseId is provided', () => { + expect(CourseActivitySummaryStore.getStateForCourse().streams).toEqual({}) + }) + + it('should return empty object for course id not already in state', () => { + const spy = jest + .spyOn(CourseActivitySummaryStore, '_fetchForCourse') + .mockImplementation(() => {}) + expect(CourseActivitySummaryStore.getStateForCourse(1)).toEqual({}) + expect(spy).toHaveBeenCalled() + CourseActivitySummaryStore.setState({streams: {1: {stream}}}) + expect(CourseActivitySummaryStore.getStateForCourse(1)).toEqual({stream}) + }) + }) + + describe('_fetchForCourse', () => { + it('populates state based on API response', async () => { + expect(CourseActivitySummaryStore.getState().streams[1]).toBeUndefined() // precondition + + const spy = jest.spyOn(window, 'fetch').mockImplementation(() => + Promise.resolve().then(() => ({ + json: () => Promise.resolve().then(() => stream) + })) + ) + CourseActivitySummaryStore._fetchForCourse(1) + await wait(1) + expect(spy).toHaveBeenCalled() + expect(CourseActivitySummaryStore.getState()).toEqual({streams: {1: {stream}}}) + }) + }) +}) diff --git a/app/jsx/navigation_header/Navigation.js b/app/jsx/navigation_header/Navigation.js index 4475d91b2dd..d40591faed2 100644 --- a/app/jsx/navigation_header/Navigation.js +++ b/app/jsx/navigation_header/Navigation.js @@ -37,6 +37,8 @@ const ACTIVE_ROUTE_REGEX = /^\/(courses|groups|accounts|grades|calendar|conversa const ACTIVE_CLASS = 'ic-app-header__menu-list-item--active' const UNREAD_COUNT_POLL_INTERVAL = 60000 // 60 seconds +const UNREAD_COUNT_ALLOWED_AGE = UNREAD_COUNT_POLL_INTERVAL / 2 +const UNREAD_COUNT_SESSION_STORAGE_KEY = `unread_count_for_${window.ENV.current_user_id}` const TYPE_URL_MAP = { courses: '/api/v1/users/self/favorites/courses?include[]=term&exclude[]=enrollments', @@ -60,7 +62,6 @@ export default class Navigation extends React.Component { help: [], profile: [], unread_count: 0, - unread_count_attempts: 0, isTrayOpen: false, type: null, coursesLoading: false, @@ -117,13 +118,23 @@ export default class Navigation extends React.Component { componentDidMount() { if ( - !this.state.unread_count_attempts && + !this.unread_count_attempts && window.ENV.current_user_id && !window.ENV.current_user_disabled_inbox && - this.unreadCountElement().length && + this.unreadCountElement() && !(window.ENV.current_user && window.ENV.current_user.fake_student) ) { - this.pollUnreadCount() + let msUntilIShouldStartPolling = 0 + const saved = sessionStorage.getItem(UNREAD_COUNT_SESSION_STORAGE_KEY) + if (saved) { + const {updatedAt, unread_count} = JSON.parse(saved) + const millisecondsSinceLastUpdate = new Date() - updatedAt + if (millisecondsSinceLastUpdate < UNREAD_COUNT_ALLOWED_AGE) { + this.updateUnreadCount(unread_count) + msUntilIShouldStartPolling = UNREAD_COUNT_ALLOWED_AGE - millisecondsSinceLastUpdate + } + } + setTimeout(() => this.pollUnreadCount(), msUntilIShouldStartPolling) } } @@ -174,26 +185,40 @@ export default class Navigation extends React.Component { return data } - pollUnreadCount() { - this.setState({unread_count_attempts: this.state.unread_count_attempts + 1}, function() { - if (this.state.unread_count_attempts <= 5) { - $.ajax('/api/v1/conversations/unread_count') - .then(data => this.updateUnreadCount(data.unread_count)) - .then(null, console.log.bind(console, 'something went wrong updating unread count')) - .always(() => - setTimeout( - () => this.pollUnreadCount(), - this.state.unread_count_attempts * UNREAD_COUNT_POLL_INTERVAL - ) - ) + async pollUnreadCount() { + this.unread_count_attempts = (this.unread_count_attempts || 0) + 1 + if (this.unread_count_attempts > 5) return + + // don't let this count against us in newRelic's SPA load time stats + const fetch = window.fetchIgnoredByNewRelic || window.fetch + + try { + const {unread_count} = await (await fetch('/api/v1/conversations/unread_count', { + headers: {Accept: 'application/json'} + })).json() + + try { + sessionStorage.setItem( + UNREAD_COUNT_SESSION_STORAGE_KEY, + JSON.stringify({ + updatedAt: +new Date(), + unread_count + }) + ) + } catch (e) { + // maybe session storage is full or something, ignore } - }) + this.updateUnreadCount(unread_count) + } catch (error) { + console.warn('something went wrong updating unread count', error) + } + setTimeout(() => this.pollUnreadCount(), this.unread_count_attempts * UNREAD_COUNT_POLL_INTERVAL) } unreadCountElement() { return ( - this.$unreadCount || - (this.$unreadCount = $('#global_nav_conversations_link').find('.menu-item__badge')) + this._unreadCountElement || + (this._unreadCountElement = $('#global_nav_conversations_link').find('.menu-item__badge')[0]) ) } @@ -212,9 +237,11 @@ export default class Navigation extends React.Component { {count} , - this.unreadCountElement()[0] + this.unreadCountElement() ) - this.unreadCountElement().toggle(count > 0) + if (this.unreadCountElement()) { + this.unreadCountElement().style.display = count > 0 ? '' : 'none' + } } determineActiveLink() { diff --git a/app/jsx/navigation_header/__tests__/Navigation.test.js b/app/jsx/navigation_header/__tests__/Navigation.test.js new file mode 100644 index 00000000000..5fe6bf55911 --- /dev/null +++ b/app/jsx/navigation_header/__tests__/Navigation.test.js @@ -0,0 +1,74 @@ +// Copyright (C) 2015 - present Instructure, Inc. +// +// This file is part of Canvas. +// +// Canvas is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, version 3 of the License. +// +// Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +// A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see . + +import $ from 'jquery' +import React from 'react' +import ReactDOM from 'react-dom' +import Navigation from 'jsx/navigation_header/Navigation' + +$(document.body).append('
') +const componentHolder = document.getElementById('holder') + +const renderComponent = () => ReactDOM.render(, componentHolder) + +let $inbox_data +describe('GlobalNavigation', () => { + beforeEach(() => { + fetch.resetMocks() + // Need to setup the global nav stuff we are testing + $inbox_data = $(` + + + + `).appendTo(document.body) + window.ENV.current_user_id = 10 + ENV.current_user_disabled_inbox = false + }) + + afterEach(() => { + ReactDOM.unmountComponentAtNode(componentHolder) + $('#holder').remove() + $inbox_data.remove() + }) + + it('renders', () => { + expect(() => renderComponent()).not.toThrow() + }) + + it('shows the inbox badge when necessary', async () => { + fetch.mockResponse(JSON.stringify({unread_count: 12})) + renderComponent() + await new Promise(resolve => setTimeout(resolve, 100)) + const $badge = $('#global_nav_conversations_link').find('.menu-item__badge') + expect($badge.text()).toBe('12 unread messages12') + expect($badge.css('display')).toBe('') + }) + + it('does not show the inbox badge when the user has opted out of notifications', async () => { + ENV.current_user_disabled_inbox = true + renderComponent() + await new Promise(resolve => setTimeout(resolve, 100)) + const $badge = $('#global_nav_conversations_link').find('.menu-item__badge') + expect($badge.text()).toBe('0') + expect($badge.css('display')).toBe('none') + }) +}) diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 80d464a1add..1a3482f8cb3 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -28,7 +28,7 @@ - <%= browser_performance_monitor_embed %> + <%= browser_performance_monitor_embed %> <%= favicon_link_tag(favicon) %> <%= favicon_link_tag(brand_variable('ic-brand-apple-touch-icon'), rel: 'apple-touch-icon', type: nil) %> <%= yield :auto_discovery %> diff --git a/jest.config.js b/jest.config.js index abfda5d7b9c..ecb405eaf64 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,7 @@ module.exports = { '^jsx/(.*)$': '/app/jsx/$1', '^jst/(.*)$': '/app/views/jst/$1', "^timezone$": "/public/javascripts/timezone_core.js", + 'node_modules-version-of-backbone': require.resolve('backbone'), "\\.svg$": "/jest/imageMock.js" }, roots: ['app/jsx', 'app/coffeescripts'], @@ -55,6 +56,7 @@ module.exports = { coverageDirectory: '/coverage-jest/', moduleFileExtensions: [...defaults.moduleFileExtensions, 'coffee', 'handlebars'], + restoreMocks: true, transform: { '^i18n': '/jest/i18nTransformer.js', diff --git a/jest/jest-setup.js b/jest/jest-setup.js index b24b751b872..ae406a6e156 100644 --- a/jest/jest-setup.js +++ b/jest/jest-setup.js @@ -21,7 +21,7 @@ import Adapter from 'enzyme-adapter-react-16' const errorsToIgnore = ["Warning: [Focusable] Exactly one tabbable child is required (0 found)."]; -window.fetch = require('unfetch') +window.fetch = require('jest-fetch-mock') /* eslint-disable-next-line */ const _consoleDotError = console.error diff --git a/package.json b/package.json index 89b8ffcfd71..b6e7445c46c 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "jest-canvas-mock": "^1", "jest-config": "^24", "jest-dom": "^3", + "jest-fetch-mock": "^2.1.2", "jest-junit": "^6", "jest-localstorage-mock": "^2", "jest-moxios-utils": "^1", @@ -212,7 +213,6 @@ "style-loader": "^0.23", "stylelint": "^9", "through2": "^2", - "unfetch": "^4.0.1", "waait": "^1", "webpack": "^4", "webpack-cleanup-plugin": "^0.5", diff --git a/spec/coffeescripts/jsx/dashboard_card/CourseActivitySummaryStoreSpec.js b/spec/coffeescripts/jsx/dashboard_card/CourseActivitySummaryStoreSpec.js deleted file mode 100644 index 7f6040c2dd1..00000000000 --- a/spec/coffeescripts/jsx/dashboard_card/CourseActivitySummaryStoreSpec.js +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2015 - present Instructure, Inc. - * - * This file is part of Canvas. - * - * Canvas is free software: you can redistribute it and/or modify it under - * the terms of the GNU Affero General Public License as published by the Free - * Software Foundation, version 3 of the License. - * - * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more - * details. - * - * You should have received a copy of the GNU Affero General Public License along - * with this program. If not, see . - */ - -import {has, isEmpty} from 'lodash' -import CourseActivitySummaryStore from 'jsx/dashboard_card/CourseActivitySummaryStore' - -QUnit.module('CourseActivitySummaryStore', { - setup() { - CourseActivitySummaryStore.setState({streams: {}}) - this.server = sinon.fakeServer.create() - this.stream = [ - { - type: 'DiscussionTopic', - unread_count: 2, - count: 7 - }, - { - type: 'Conversation', - unread_count: 0, - count: 3 - } - ] - }, - teardown() { - return this.server.restore() - } -}) - -test('getStateForCourse', function() { - ok( - has(CourseActivitySummaryStore.getStateForCourse(), 'streams'), - 'should return root state object when no courseId is provided' - ) - const spy = sandbox.stub(CourseActivitySummaryStore, '_fetchForCourse').returns(true) - ok( - isEmpty(CourseActivitySummaryStore.getStateForCourse(1)), - 'should return empty object for course id not already in state' - ) - ok(spy.called, 'should call _fetchForCourse to fetch stream info for course') - CourseActivitySummaryStore.setState({streams: {1: {stream: this.stream}}}) - deepEqual( - CourseActivitySummaryStore.getStateForCourse(1), - {stream: this.stream}, - 'should return stream if present' - ) -}) - -test('_fetchForCourse', function() { - ok(isEmpty(CourseActivitySummaryStore.getState().streams[1]), 'precondition') - this.server.respondWith('GET', '/api/v1/courses/1/activity_stream/summary', [ - 200, - {'Content-Type': 'application/json'}, - JSON.stringify(this.stream) - ]) - CourseActivitySummaryStore._fetchForCourse(1) - this.server.respond() - deepEqual( - CourseActivitySummaryStore.getState().streams[1].stream, - this.stream, - 'should populate state based on API response' - ) -}) diff --git a/spec/javascripts/jsx/navigation_header/NavigationHeaderSpec.js b/spec/javascripts/jsx/navigation_header/NavigationHeaderSpec.js deleted file mode 100644 index 35c72cff7c9..00000000000 --- a/spec/javascripts/jsx/navigation_header/NavigationHeaderSpec.js +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (C) 2015 - present Instructure, Inc. -// -// This file is part of Canvas. -// -// Canvas is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License as published by the Free -// Software Foundation, version 3 of the License. -// -// Canvas is distributed in the hope that it will be useful, but WITHOUT ANY -// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -// A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -// details. -// -// You should have received a copy of the GNU Affero General Public License along -// with this program. If not, see . - -import $ from 'jquery' -import React from 'react' -import ReactDOM from 'react-dom' -import Navigation from 'jsx/navigation_header/Navigation' - -const wrapper = document.getElementById('fixtures') -$(wrapper).append('
') -const componentHolder = document.getElementById('holder') - -const renderComponent = function() { - return ReactDOM.render(, componentHolder) -} - -QUnit.module('GlobalNavigation', { - setup() { - // Need to setup the global nav stuff we are testing - this.$inbox_data = $(` - - - - `).appendTo(wrapper) - - this.server = sinon.fakeServer.create() - window.ENV.current_user_id = 10 - ENV.current_user_disabled_inbox = false - const response = {unread_count: 10} - this.server.respondWith('GET', /unread/, [ - 200, - {'Content-Type': 'application/json'}, - JSON.stringify(response) - ]) - }, - - teardown() { - this.server.restore() - ReactDOM.unmountComponentAtNode(componentHolder) - $('#holder').remove() - this.$inbox_data.remove() - } -}) - -test('it renders', function() { - this.component = renderComponent() - ok(this.component) -}) - -test('shows the inbox badge when necessary', function() { - this.component = renderComponent() - this.server.respond() - const $badge = $('#global_nav_conversations_link').find('.menu-item__badge') - ok($badge.is(':visible')) -}) - -test('does not show the inbox badge when the user has opted out of notifications', function() { - ENV.current_user_disabled_inbox = true - this.component = renderComponent() - this.server.respond() - const $badge = $('#global_nav_conversations_link').find('.menu-item__badge') - notOk($badge.is(':visible')) -}) diff --git a/yarn.lock b/yarn.lock index e27be1f70aa..5d32231c64d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6116,6 +6116,14 @@ cross-fetch@2.2.2: node-fetch "2.1.2" whatwg-fetch "2.0.4" +cross-fetch@^2.2.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-2.2.3.tgz#e8a0b3c54598136e037f8650f8e823ccdfac198e" + integrity sha512-PrWWNH3yL2NYIb/7WF/5vFG3DCQiXDOVf8k3ijatbrtnwNuhMWLC7YF7uqf53tbTFDzHIUD8oITw4Bxt8ST3Nw== + dependencies: + node-fetch "2.1.2" + whatwg-fetch "2.0.4" + cross-spawn@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" @@ -11508,6 +11516,14 @@ jest-environment-node@^24.8.0: jest-mock "^24.8.0" jest-util "^24.8.0" +jest-fetch-mock@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-2.1.2.tgz#1260b347918e3931c4ec743ceaf60433da661bd0" + integrity sha512-tcSR4Lh2bWLe1+0w/IwvNxeDocMI/6yIA2bijZ0fyWxC4kQ18lckQ1n7Yd40NKuisGmcGBRFPandRXrW/ti/Bw== + dependencies: + cross-fetch "^2.2.2" + promise-polyfill "^7.1.1" + jest-get-type@^24.8.0: version "24.8.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.8.0.tgz#a7440de30b651f5a70ea3ed7ff073a32dfe646fc" @@ -15671,6 +15687,11 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise-polyfill@^7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-7.1.2.tgz#ab05301d8c28536301622d69227632269a70ca3b" + integrity sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ== + promise@^7.0.3, promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -19186,11 +19207,6 @@ underscore@~1.7.0: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209" integrity sha1-a7rwh3UA02vjTsqlhODbn+8DUgk= -unfetch@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.1.0.tgz#6ec2dd0de887e58a4dee83a050ded80ffc4137db" - integrity sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg== - unherit@^1.0.4: version "1.1.2" resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.2.tgz#14f1f397253ee4ec95cec167762e77df83678449"