Insert language polyfills at page load time

Refs FOO-1891
flag=none

Canvas officially supports a fixed set of languages and locales.
Unfortunately, not all browsers support all of those (the worst
offender oddly enough is Google Chrome). Fortunately, polyfills
for almost all of the Canvas supported languages are available
via a polyfill package.

This commit introduces a function that asynchronously loads the
polyfill for a requested language. It does nothing if it sees
there is already native support in the browser for that
language. The function can be imported and run to dynamically
switch in and out the polyfills as necessary, but since Canvas
never changes ENV.LOCALE except at page load time, it should
suffice to call it at front end initialization time; this was
added to ui/index.js as a part of its "readiness" checks.

The missing one (that NO browser supports) is Haitian Creole;
we will have to probably build our own locale data file for
that one.

This takes care of polyfilling DateTimeFormat, NumberFormat,
and RelativeTimeFormat. We can always add other Intl systems
if the need arises.

Test plan:
* Be on Chrome (which does not support the Welsh language)
* In the browser console, verify no native support for Welsh:
      Intl.DateTimeFormat.supportedLocalesOf(['cy'])
      ... should return an empty array
* Go into your user settings on Canvas and change your language
  to Welsh (Cymraeg). Reload the page to make it take effect.
  Now your settings page should appear in Welsh.
* In the browser console, verify that dates and times can be
  formatted correctly in Welsh (thanks to the polyfill):
      Intl.DateTimeFormat('cy', {
        dateStyle: 'long',
        timeStyle: 'long'
      }).format(new Date())
* That should display a date and time in Welsh (most prominently
  seen in the name of the month).

Change-Id: I40632344ba1d8679aba1a976fcb55af97636be4b
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/267359
Reviewed-by: Ahmad Amireh <ahmad@instructure.com>
Product-Review: Charley Kline <ckline@instructure.com>
Product-Review: Ahmad Amireh <ahmad@instructure.com>
QA-Review: Ahmad Amireh <ahmad@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Charley Kline 2021-06-16 21:43:17 -05:00 committed by Ahmad Amireh
parent 285890e783
commit bb5ced7951
6 changed files with 201 additions and 30 deletions

View File

@ -60,7 +60,7 @@ module.exports = {
// This just reflects how big the 'main' entry is at the time of writing. Every
// time we get it smaller we should change this to the new smaller number so it
// only goes down over time instead of growing bigger over time
maxEntrypointSize: 1200000,
maxEntrypointSize: 1230000,
// This is how big our biggest js bundles are at the time of writing. We should
// first work to attack the things in `thingsWeKnowAreWayTooBig` so we can start
// tracking them too. Then, as we work to get all chunks smaller, we should change

View File

@ -121,6 +121,7 @@
"immer": "^3",
"immutability-helper": "^3",
"immutable": "^3.8.2",
"intl-polyfills": "^1",
"is-valid-domain": "^0.0.11",
"jquery": "https://github.com/instructure/jquery.git#1.7.2-with-AMD-and-CommonJS",
"jquery-getscrollbarwidth": "^1.0.0",

View File

@ -16,8 +16,6 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// All of these only implement the en-US locale, as do most of our tests anyway.
// Now that Canvas is on Node 14, there is at least some support for the ICU
// functionality. In case it is not 100% complete, though, this skeleton will
// remain in case we need to add any Intl polyfills in the future. Hopefully
@ -26,3 +24,126 @@
export function installIntlPolyfills() {
if (typeof window.Intl === 'undefined') window.Intl = {}
}
class LocaleLoadError extends Error {
constructor(result, ...rest) {
super(...rest)
Error.captureStackTrace && Error.captureStackTrace(this, LocaleLoadError)
this.name = 'LocaleLoadError'
this.result = result
}
}
//
// Intl polyfills for locale-specific output of Dates, Times, Numbers, and
// Relative Times.
//
const nativeSubsystem = {
DateTimeFormat: Intl.DateTimeFormat,
NumberFormat: Intl.NumberFormat,
RelativeTimeFormat: Intl.RelativeTimeFormat
}
const polyfilledSubsystem = {}
const polyfillImports = {
DateTimeFormat: async () => {
await import('@formatjs/intl-datetimeformat/polyfill')
return import('@formatjs/intl-datetimeformat/add-all-tz')
},
NumberFormat: () => import('@formatjs/intl-numberformat/polyfill'),
RelativeTimeFormat: () => import('@formatjs/intl-relativetimeformat/polyfill')
}
const localeImports = {
DateTimeFormat: l => import(`@formatjs/intl-datetimeformat/locale-data/${l}`),
NumberFormat: l => import(`@formatjs/intl-numberformat/locale-data/${l}`),
RelativeTimeFormat: l => import(`@formatjs/intl-relativetimeformat/locale-data/${l}`)
}
function reset(locale, subsystem) {
Intl[subsystem] = nativeSubsystem[subsystem] // Check browser-native Intl sources first
const localeNotNative = Intl[subsystem].supportedLocalesOf([locale]).length === 0
return {subsystem, locale, source: localeNotNative ? 'polyfill' : 'native'}
}
// Mark a return result as a fallback locale
const fallback = r => ({...r, source: 'fallback'})
// This utility is called when changing locales midstride (Canvas normally never
// changes ENV.LOCALE except at a page load). It will if necessary dynamically load
// language and locale support for locales not supported natively in the browser.
// Called with the desired locale string and a string representing which Intl subsystem
// is to be operated on ('DateTimeFormat', 'NumberFormat', or 'RelativeTimeFormat')
//
// Returns a Promise which will resolve to an object with properties:
// subsystem - the Intl subsystem being (possibly) polyfilled
// locale - the locale that was loaded
// source - one of 'native' or 'polyfill', indicating whether the requested locale
// locale is native to the browser or was loaded via a polyfill
//
// If the polyfill fails to load, the language falls back to whatever the browser's native
// language is (navigator.language), and the Promise rejects, returning a custom error
// LocaleLoadError with the `message` explaining the failure and a `result` property which
// is an object as described above, which will indicate the fallback state of the locale
// after the failure causing the error to be thrown.
async function doPolyfill(locale, subsys) {
// Reset back to the native browser Intl subsystem and see if the requested locale
// is one of its supported ones. If not, we need to polyfill it. First import the
// polyfilled subsystem itself. We can only do this once as that import is not
// idempotent, which is why we save the polyfills and only do the import if there
// is no saved one.
const result = reset(locale, subsys)
if (result.source === 'polyfill') {
delete Intl[subsys]
if (typeof polyfilledSubsystem[subsys] === 'undefined') {
try {
await polyfillImports[subsys]()
polyfilledSubsystem[subsys] = Intl[subsys]
} catch (e) {
// restore native one and throw an error
throw new LocaleLoadError(fallback(reset(navigator.language, subsys)), e.message)
}
} else {
// Have already loaded the polyfill, just need to stuff it back in
Intl[subsys] = polyfilledSubsystem[subsys]
}
// Now load the specific locale... if it fails, then that locale does not
// exist and we have no choice but to fall back to the navigator language.
// Note that loading a locale onto the polyfill *is* idempotent, so it
// won't matter if we do this multiple times.
try {
await localeImports[subsys](locale)
} catch (e) {
throw new LocaleLoadError(fallback(reset(navigator.language, subsys)), e.message)
}
}
return result
}
// Convenience functions that load the Intl polyfill for each of the three
// supported subsystems supported here (we could add more if needed)
export function loadDateTimeFormatPolyfill(locale) {
return doPolyfill(locale, 'DateTimeFormat')
}
export function loadNumberFormatPolyfill(locale) {
return doPolyfill(locale, 'NumberFormat')
}
export function loadRelativeTimeFormatPolyfill(locale) {
return doPolyfill(locale, 'RelativeTimeFormat')
}
// Grand cru convenience function that (maybe) polyfills everything here.
// Returns a Promise that resolves to an array of the result objects
// (see above) for each subsystem.
export function loadAllLocalePolyfills(locale) {
return Promise.all([
loadDateTimeFormatPolyfill(locale),
loadNumberFormatPolyfill(locale),
loadRelativeTimeFormatPolyfill(locale)
])
}

View File

@ -2,6 +2,11 @@
"name": "intl-polyfills",
"private": true,
"version": "1.0.0",
"author": "neme",
"main": "./index.js"
"author": "Charley Kline, ckline@instructure.com",
"main": "./index.js",
"dependencies": {
"@formatjs/intl-datetimeformat": "^4",
"@formatjs/intl-numberformat": "^7",
"@formatjs/intl-relativetimeformat": "^9"
}
}

View File

@ -16,9 +16,9 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import './boot/initializers/setWebpackCdnHost.js'
import './boot/initializers/setWebpackCdnHost'
import '@canvas/jquery/jquery.instructure_jquery_patches' // this needs to be before anything else that requires jQuery
import './boot/index.js'
import './boot'
// true modules that we use in this file
import $ from 'jquery'
@ -35,24 +35,24 @@ import loadBundle from 'bundles-generated'
import 'translations/_core_en'
import '@canvas/jquery/jquery.ajaxJSON'
import '@canvas/forms/jquery/jquery.instructure_forms'
import './boot/initializers/ajax_errors.js'
import './boot/initializers/activateKeyClicks.js'
import './boot/initializers/activateTooltips.js'
import './boot/initializers/ajax_errors'
import './boot/initializers/activateKeyClicks'
import './boot/initializers/activateTooltips'
window.canvasReadyState = 'loading'
window.dispatchEvent(new CustomEvent('canvasReadyStateChange'))
const readinessTargets = [
['asyncInitializers',false],
['deferredBundles',false],
['asyncInitializers', false],
['deferredBundles', false],
['localePolyfills', false]
]
const advanceReadiness = target => {
const entry = readinessTargets.find(x => x[0] === target)
if (!entry) {
throw new Error(`Invalid readiness target -- "${target}"`)
}
else if (entry[1]) {
} else if (entry[1]) {
throw new Error(`Target already marked ready -- "${target}"`)
}
@ -77,25 +77,23 @@ window.bundles.forEach(loadBundle)
if (ENV.csp)
// eslint-disable-next-line promise/catch-or-return
import('./boot/initializers/setupCSP.js').then(({default: setupCSP}) =>
setupCSP(window.document)
)
if (ENV.INCOMPLETE_REGISTRATION) import('./boot/initializers/warnOnIncompleteRegistration.js')
if (ENV.badge_counts) import('./boot/initializers/showBadgeCounts.js')
import('./boot/initializers/setupCSP').then(({default: setupCSP}) => setupCSP(window.document))
if (ENV.INCOMPLETE_REGISTRATION) import('./boot/initializers/warnOnIncompleteRegistration')
if (ENV.badge_counts) import('./boot/initializers/showBadgeCounts')
$('html').removeClass('scripts-not-loaded')
$('.help_dialog_trigger').click(event => {
event.preventDefault()
// eslint-disable-next-line promise/catch-or-return
import('./boot/initializers/enableHelpDialog.js').then(({default: helpDialog}) => helpDialog.open())
import('./boot/initializers/enableHelpDialog').then(({default: helpDialog}) => helpDialog.open())
})
// Backbone routes
$('body').on(
'click',
'[data-pushstate]',
preventDefault(function() {
preventDefault(function () {
Backbone.history.navigate($(this).attr('href'), true)
})
)
@ -107,23 +105,38 @@ if (
splitAssetString(window.ENV.context_asset_string)[0] === 'courses'
) {
// eslint-disable-next-line promise/catch-or-return
import('./features/new_user_tutorial/index').then(
({default: initializeNewUserTutorials}) => {
initializeNewUserTutorials()
}
)
import('./features/new_user_tutorial/index').then(({default: initializeNewUserTutorials}) => {
initializeNewUserTutorials()
})
}
;(window.requestIdleCallback || window.setTimeout)(() => {
import('./boot/initializers/runOnEveryPageButDontBlockAnythingElse.js').then(() =>
// eslint-disable-next-line promise/catch-or-return
import('./boot/initializers/runOnEveryPageButDontBlockAnythingElse').then(() =>
advanceReadiness('asyncInitializers')
)
})
// Load Intl polyfills if necessary given the ENV.LOCALE. Advance the readiness
// state whether that worked or not.
/* eslint-disable no-console */
import('intl-polyfills')
.then(im => im.loadAllLocalePolyfills(ENV.LOCALE))
.catch(e => {
console.error(
`Problem loading locale polyfill for ${ENV.LOCALE}, falling back to ${e.result.locale}`
)
console.error(e.message)
})
.then(() => advanceReadiness('localePolyfills'))
/* eslint-enable no-console */
ready(() => {
Promise.all(
(window.deferredBundles || []).map(loadBundle)
).then(() => advanceReadiness('deferredBundles'))
// eslint-disable-next-line promise/catch-or-return
Promise.all((window.deferredBundles || []).map(loadBundle)).then(() =>
advanceReadiness('deferredBundles')
)
// LS-1662: there are math equations on the page that
// we don't see, so remain invisible and aren't

View File

@ -1346,6 +1346,37 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@formatjs/ecma402-abstract@1.9.4":
version "1.9.4"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.4.tgz#797ae6c407fb5a0d09023a60c86f19aca1958c5e"
integrity sha512-ePJXI7tWC9PBxQxS7jtbkCLGVmpC8MH8n9Yjmg8dsh9wXK9svu7nAbq76Oiu5Zb+5GVkLkeTVerlSvHCbNImlA==
dependencies:
tslib "^2.1.0"
"@formatjs/intl-datetimeformat@^4":
version "4.1.6"
resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-4.1.6.tgz#ce30d3907dfb229c79e023c03171da09b2611e36"
integrity sha512-o4M/RzBNAC/LmOwAd3W/3bsCT7xl9luddNdX9x1uZroS/8Wx6M8sE/KMNDCSZogZtmMasCBvpNmuQoeMyBBASg==
dependencies:
"@formatjs/ecma402-abstract" "1.9.4"
tslib "^2.1.0"
"@formatjs/intl-numberformat@^7":
version "7.1.5"
resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-7.1.5.tgz#8d1df875f7dfbd2ed8e1326fddb5f4dad3c66d11"
integrity sha512-1U6IEp/1vf2XzfDjLWqJuQufRZY3/F9UfOCAVp14eYVb/+gZmG4Q3heJQZXOAKnnQl3YGG73Ez7UgFVo/rowGQ==
dependencies:
"@formatjs/ecma402-abstract" "1.9.4"
tslib "^2.1.0"
"@formatjs/intl-relativetimeformat@^9":
version "9.1.7"
resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-9.1.7.tgz#9c5481b4d4ac39de3162c836cedef52feeae8a05"
integrity sha512-uthmRnNg+agzulNxa4Em2Jbll1msbJdkb6CO8JrOg/CFHyXH9YPMaPjCxgzR2wdDeMjBjGnFhbTJZtqz5LN+Uw==
dependencies:
"@formatjs/ecma402-abstract" "1.9.4"
tslib "^2.1.0"
"@gulp-sourcemaps/identity-map@1.X":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9"