Redo the locale polyfiller using new features from @formatjs

Fixes FOO-2330
flag=none

Canvas supports locales that not all browsers support, so we have
to check for those cases and pull in a polyfilled version of the
Intl functions that we use (DateTimeFormat, NumberFormat, and
RelativeTimeFormat) when a locale is called for that the browser
does not support.

The first time the locale polyfiller was implemented, a lot of
tests had to be done to see if there was an available polyfill for
the given locale; this somehow broke Chrome after Version 92 so it
was disabled for Chrome. However, latest versions of the @formatjs
polyfillers are much smarter and can perform those checks and
fallbacks themselves, which makes the Canvas polyfiller code way
simpler and more stable.

An additional issue was uncovered: when the locale is being polyfilled,
NONE of the native locales can be accessed any more, so code relying on
being able to get at, say, 'en-US' to do date arithmetic was failing.
So when a polyfill loads, we save the browser's version as Native*
(so Intl.DateTimeFormat --> Intl.NativeDateTimeFormat) so it can
be accessed if needed (shouldn't be anywhere except in the ui/shared
datetime support code).

Another issue uncovered is that when a polyfill is necessary, it
seems to take a long time... it can be SECONDS before it is fully
loaded and its Promises resolve, at least in dev. Unfortunately a
lot of front-end code can start up and begin to use Intl functions
during that time, possibly with unpredictable results. The only real
answer is to make the execution of immediate bundles, and the call
to ready() to finish front-end initialization, wait pending the
resolution of the Promise indicating that the locales have loaded.
This is a bit unfortunate as it delays the execution of front-end
code, but will only be noticeable in locales that need polyfilling
because if a native locale is detected, the bundle loading and
call to ready() will happen immediately as before.

This commit does the following:

  - changes the Rails js_env to include LOCALES instead of LOCALE.
    LOCALES is a full list of locale strings in fallback order, so
    that if `sv-SE-x-k12` isn't found, it will try `sv-SE` and
    `sv-x-k12` and `sv` before finally falling back to `en`.
    The front-end startup code backfills ENV.LOCALE to ENV.LOCALES[0]
    for backward compatibility.

  - up-revs @formatjs to a more recent version that implements the
    check if a polyfill is available and leverages the LOCALES list
    to find an available polyfill.

  - Rewrites intl-polyfills to be smarter about detecting when a
    polyfill is necessary and whether it is possible.

  - Makes it easier to add new Intl subsystems to the polyfill code
    should they start getting used in the Canvas code somewhere.

  - Fixes up the helpers that relied upon the 'en-US' locale
    specifically.  They now specifically check for the Native version
    of an Intl subsystem first.

  - Fixes up the changeTimezone helpers in ui/shared/datetime because
    they relied on the `en-US` locale always being available, which
    is not true when the locale has been polyfilled. That was kind of
    sketchy anyway because it relied on new Date() parsing a fairly
    free-form English date and time string; it now constructs an
    ISO8601 string which is much more supported as an argument to
    new Date().

Test plan:
* Try some different locale scenarios and observe the behavior of
  Canvas in that language in general, and also specifically how it
  formats dates and times (this may be faster to do in console).
* Pay particular attention to places where datepickers are used,
  specifically, create a new assignment and fiddle with the due
  dates down near the bottom.
* Locale scenarios to try:
  a native locale like de or es
    -should just work
  a native locale with an extension like sv-x-k12
    -should just work
  a polyfillable locale like cy or nn
    -should work with a console message saying it is polyfilled
  the one non-polyfillable locale: Kreyòl Ayisyen
    -should work via a fallback all the way to 'en' on console

Change-Id: I93a1f2c3f0b3002747f564aba24c93d77244383e
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/284509
Reviewed-by: Ahmad Amireh <ahmad@instructure.com>
QA-Review: Charley Kline <ckline@instructure.com>
Product-Review: Charley Kline <ckline@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Charley Kline 2022-02-04 18:03:25 -06:00
parent 8af10493a2
commit 3af92969b2
9 changed files with 256 additions and 237 deletions

View File

@ -250,9 +250,9 @@ class ApplicationController < ActionController::Base
@js_env[:ping_url] = polymorphic_url([:api_v1, @context, :ping]) if @context.is_a?(Course) @js_env[:ping_url] = polymorphic_url([:api_v1, @context, :ping]) if @context.is_a?(Course)
@js_env[:TIMEZONE] = Time.zone.tzinfo.identifier unless @js_env[:TIMEZONE] @js_env[:TIMEZONE] = Time.zone.tzinfo.identifier unless @js_env[:TIMEZONE]
@js_env[:CONTEXT_TIMEZONE] = @context.time_zone.tzinfo.identifier if !@js_env[:CONTEXT_TIMEZONE] && @context.respond_to?(:time_zone) && @context.time_zone.present? @js_env[:CONTEXT_TIMEZONE] = @context.time_zone.tzinfo.identifier if !@js_env[:CONTEXT_TIMEZONE] && @context.respond_to?(:time_zone) && @context.time_zone.present?
unless @js_env[:LOCALE] unless @js_env[:LOCALES]
I18n.set_locale_with_localizer I18n.set_locale_with_localizer
@js_env[:LOCALE] = I18n.locale.to_s @js_env[:LOCALES] = I18n.fallbacks[I18n.locale].map(&:to_s)
@js_env[:BIGEASY_LOCALE] = I18n.bigeasy_locale @js_env[:BIGEASY_LOCALE] = I18n.bigeasy_locale
@js_env[:FULLCALENDAR_LOCALE] = I18n.fullcalendar_locale @js_env[:FULLCALENDAR_LOCALE] = I18n.fullcalendar_locale
@js_env[:MOMENT_LOCALE] = I18n.moment_locale @js_env[:MOMENT_LOCALE] = I18n.moment_locale

View File

@ -21,43 +21,31 @@
// remain in case we need to add any Intl polyfills in the future. Hopefully // remain in case we need to add any Intl polyfills in the future. Hopefully
// we will not ever have to. // we will not ever have to.
import {shouldPolyfill as spfNF} from '@formatjs/intl-numberformat/should-polyfill'
import {shouldPolyfill as spfDTF} from '@formatjs/intl-datetimeformat/should-polyfill'
import {shouldPolyfill as spfRTF} from '@formatjs/intl-relativetimeformat/should-polyfill'
export function installIntlPolyfills() { export function installIntlPolyfills() {
if (typeof window.Intl === 'undefined') window.Intl = {} if (typeof window.Intl === 'undefined') window.Intl = {}
} }
class LocaleLoadError extends Error { const shouldPolyfill = {
constructor(result, ...rest) { NumberFormat: spfNF,
super(...rest) DateTimeFormat: spfDTF,
Error.captureStackTrace && Error.captureStackTrace(this, LocaleLoadError) RelativeTimeFormat: spfRTF
this.name = 'LocaleLoadError'
this.result = result
}
}
function chromeVersion() {
const m = navigator.userAgent.match(new RegExp('Chrom(e|ium)/([0-9]+).'))
return m ? parseInt(m[2], 10) : 0
} }
// //
// Intl polyfills for locale-specific output of Dates, Times, Numbers, and // Intl polyfills for locale-specific output of Dates, Times, Numbers, and
// Relative Times. // Relative Times.
// //
const nativeSubsystem = {
DateTimeFormat: Intl.DateTimeFormat,
NumberFormat: Intl.NumberFormat,
RelativeTimeFormat: Intl.RelativeTimeFormat
}
const polyfilledSubsystem = {}
const polyfillImports = { const polyfillImports = {
DateTimeFormat: async () => { DateTimeFormat: async () => {
await import('@formatjs/intl-datetimeformat/polyfill') await import('@formatjs/intl-datetimeformat/polyfill-force')
return import('@formatjs/intl-datetimeformat/add-all-tz') return import('@formatjs/intl-datetimeformat/add-all-tz')
}, },
NumberFormat: () => import('@formatjs/intl-numberformat/polyfill'), NumberFormat: () => import('@formatjs/intl-numberformat/polyfill-force'),
RelativeTimeFormat: () => import('@formatjs/intl-relativetimeformat/polyfill') RelativeTimeFormat: () => import('@formatjs/intl-relativetimeformat/polyfill-force')
} }
const localeImports = { const localeImports = {
@ -66,114 +54,63 @@ const localeImports = {
RelativeTimeFormat: l => import(`@formatjs/intl-relativetimeformat/locale-data/${l}`) RelativeTimeFormat: l => import(`@formatjs/intl-relativetimeformat/locale-data/${l}`)
} }
function reset(locale, subsystem) { // Check to see if there is native support in the specified Intl subsystem for
Intl[subsystem] = nativeSubsystem[subsystem] // Check browser-native Intl sources first // any of the locales given in the list (they are tried in order). If there is not
const localeNotNative = Intl[subsystem].supportedLocalesOf([locale]).length === 0 // load an appropriate locale polyfill for the list of locales from @formatjs.
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: // Return value is a Promise which resolves to an hash with the following 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 // subsys - the subsystem being operated on
// language is (navigator.language), and the Promise rejects, returning a custom error // locale - the locale that was requested
// LocaleLoadError with the `message` explaining the failure and a `result` property which // loaded - the locale that was actually polyfilled (is missing, an error occurred)
// is an object as described above, which will indicate the fallback state of the locale // source - how that locale is available ('native' or 'polyfill')
// after the failure causing the error to be thrown. // error - if an error occurred, contains the error message
//
// In most cases, if none of the locales in the list have either native support
// nor can any of them be polyfilled, the subsystem will fall back to 'en' as a
// locale (this is what the browser's native Intl would also do).
//
async function doPolyfill(givenLocales, subsys) {
// 'en' is the final fallback, don't settle for that unless it's the only
// available locale, in which case we do nothing.
const locales = [...givenLocales]
if (locales.length < 1 || (locales.length === 1 && locales[0] === 'en'))
return {subsys, locale: 'en', source: 'native'}
if (locales.slice(-1)[0] === 'en') locales.pop()
async function doPolyfill(locale, subsys) { try {
// Reset back to the native browser Intl subsystem and see if the requested locale /* eslint-disable no-await-in-loop */ // it's actually fine in for-loops
// is one of its supported ones. If not, we need to polyfill it. First import the for (const locale of locales) {
// polyfilled subsystem itself. We can only do this once as that import is not const native = Intl[subsys].supportedLocalesOf([locale])
// idempotent, which is why we save the polyfills and only do the import if there if (native.length > 0) return {subsys, locale: native[0], source: 'native'}
// is no saved one.
const result = reset(locale, subsys)
if (result.source === 'polyfill') {
// Does the requested polyfill locale exist at all? If not, do not proceed,
// it breaks some browser behavior around .toLocaleDateString() [???].
try {
await localeImports[subsys](locale)
} catch (e) {
throw new LocaleLoadError(
{
locale: navigator.language,
subsystem: subsys,
source: 'fallback'
},
e.message
)
}
delete Intl[subsys] const doable = shouldPolyfill[subsys](locale)
if (typeof polyfilledSubsystem[subsys] === 'undefined') { if (!doable || doable === 'en') continue
try { const origSubsys = Intl[subsys]
await polyfillImports[subsys]() await polyfillImports[subsys]()
polyfilledSubsystem[subsys] = Intl[subsys] await localeImports[subsys](doable)
} catch (e) { Intl[`Native${subsys}`] = origSubsys
// restore native one and throw an error return {subsys, locale, source: 'polyfill', loaded: doable}
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 (it shouldn't because we
// already checked this above!!), then 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)
} }
/* eslint-enable no-await-in-loop */
return {subsys, locale: locales[0], error: 'polyfill unavailable'}
} catch (e) {
return {subsys, locale: locales[0], error: e.message}
} }
return result
} }
// Convenience functions that load the Intl polyfill for each of the three // (Possibly) load the Intl polyfill for each of the given subsystems,
// supported subsystems supported here (we could add more if needed) // for the best available locale in the given list.
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 // Returns a Promise that resolves to an array of the result objects
// (see above) for each subsystem. // (see above) for each subsystem.
// // It is an error for the subsystems array to contain the name of an
// TEMPORARY PATCH (CNVS-53338) ... these polyfillers break certain date // Intl subsystem that we are not prepared to polyfill.
// and time Intl functions in recent versions of Chrome. For now, just export function loadAllLocalePolyfills(locales, subsystems) {
// skip. subsystems.forEach(sys => {
export function loadAllLocalePolyfills(locale) { if (!Object.keys(shouldPolyfill).includes(sys)) {
const ver = chromeVersion() throw new RangeError(`Intl subsystem ${sys} is not polyfillable!`)
if (ver >= 92) { }
// eslint-disable-next-line no-console })
console.info(`Skipping language polyfills for Chrome ${ver}`)
return null return Promise.all(subsystems.map(sys => doPolyfill(locales, sys)))
}
return Promise.all([
loadDateTimeFormatPolyfill(locale),
loadNumberFormatPolyfill(locale),
loadRelativeTimeFormatPolyfill(locale)
])
} }

View File

@ -5,8 +5,8 @@
"author": "Charley Kline, ckline@instructure.com", "author": "Charley Kline, ckline@instructure.com",
"main": "./index.js", "main": "./index.js",
"dependencies": { "dependencies": {
"@formatjs/intl-datetimeformat": "^4", "@formatjs/intl-datetimeformat": "^4.5",
"@formatjs/intl-numberformat": "^7", "@formatjs/intl-numberformat": "^7.4",
"@formatjs/intl-relativetimeformat": "^9" "@formatjs/intl-relativetimeformat": "^9.5"
} }
} }

View File

@ -122,10 +122,10 @@ RSpec.describe ApplicationController do
expect(controller.js_env[:files_domain]).to eq "files.example.com" expect(controller.js_env[:files_domain]).to eq "files.example.com"
end end
it "auto-sets timezone and locale" do it "auto-sets timezone and locales" do
I18n.with_locale(:fr) do I18n.with_locale(:fr) do
Time.use_zone("Alaska") do Time.use_zone("Alaska") do
expect(@controller.js_env[:LOCALE]).to eq "fr" expect(@controller.js_env[:LOCALES]).to eq ["fr", "en"] # 'en' is always the last fallback
expect(@controller.js_env[:BIGEASY_LOCALE]).to eq "fr_FR" expect(@controller.js_env[:BIGEASY_LOCALE]).to eq "fr_FR"
expect(@controller.js_env[:FULLCALENDAR_LOCALE]).to eq "fr" expect(@controller.js_env[:FULLCALENDAR_LOCALE]).to eq "fr"
expect(@controller.js_env[:MOMENT_LOCALE]).to eq "fr" expect(@controller.js_env[:MOMENT_LOCALE]).to eq "fr"

View File

@ -57,10 +57,9 @@ describe "calendar2" do
event_dialog.find(".edit_assignment_option").click event_dialog.find(".edit_assignment_option").click
wait_for_ajaximations wait_for_ajaximations
event_dialog.find("#assignment_title").send_keys("saturday assignment") event_dialog.find("#assignment_title").send_keys("saturday assignment")
event_dialog.find(".datetime_field").clear
# take next week's monday and advance to saturday from the current date # take next week's monday and advance to saturday from the current date
due_date = "Dec 26, 2015 at 8pm" due_date = "Dec 26, 2015 at 8pm"
event_dialog.find(".datetime_field").send_keys(due_date) replace_content(event_dialog.find(".datetime_field"), due_date)
assignment_form = event_dialog.find("#edit_assignment_form") assignment_form = event_dialog.find("#edit_assignment_form")
submit_form(assignment_form) submit_form(assignment_form)
wait_for_ajaximations wait_for_ajaximations

View File

@ -43,6 +43,9 @@ import './boot/initializers/injectAuthTokenIntoForms'
window.canvasReadyState = 'loading' window.canvasReadyState = 'loading'
window.dispatchEvent(new CustomEvent('canvasReadyStateChange')) window.dispatchEvent(new CustomEvent('canvasReadyStateChange'))
// Backfill LOCALE from LOCALES
if (!ENV.LOCALE && ENV.LOCALES instanceof Array) ENV.LOCALE = ENV.LOCALES[0]
const readinessTargets = [ const readinessTargets = [
['asyncInitializers', false], ['asyncInitializers', false],
['deferredBundles', false], ['deferredBundles', false],
@ -65,79 +68,11 @@ const advanceReadiness = target => {
} }
} }
// This is because most pages use this and by having it all in it's own chunk it makes webpack function afterDocumentReady() {
// split out a ton of stuff (like @instructure/ui-view) into multiple chunks because its chunking
// algorithm decides that because that chunk would either be too small or it would cause more than
// our maxAsyncRequests it should concat it into mutlple parents.
require.include('./features/navigation_header')
if (!window.bundles) window.bundles = []
window.bundles.push = loadBundle
// process any queued ones
window.bundles.forEach(loadBundle)
if (ENV.csp)
// eslint-disable-next-line promise/catch-or-return // eslint-disable-next-line promise/catch-or-return
import('./boot/initializers/setupCSP').then(({default: setupCSP}) => setupCSP(window.document)) Promise.all((window.deferredBundles || []).map(loadBundle)).then(() => {
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').then(({default: helpDialog}) => helpDialog.open())
})
// Backbone routes
$('body').on(
'click',
'[data-pushstate]',
preventDefault(function () {
Backbone.history.navigate($(this).attr('href'), true)
})
)
if (
window.ENV.NEW_USER_TUTORIALS &&
window.ENV.NEW_USER_TUTORIALS.is_enabled &&
window.ENV.context_asset_string &&
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()
})
}
;(window.requestIdleCallback || window.setTimeout)(() => {
// 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)
})
.finally(() => advanceReadiness('localePolyfills'))
/* eslint-enable no-console */
ready(() => {
// eslint-disable-next-line promise/catch-or-return
Promise.all((window.deferredBundles || []).map(loadBundle)).then(() =>
advanceReadiness('deferredBundles') advanceReadiness('deferredBundles')
) })
// LS-1662: there are math equations on the page that // LS-1662: there are math equations on the page that
// we don't see, so remain invisible and aren't // we don't see, so remain invisible and aren't
@ -195,4 +130,106 @@ ready(() => {
childList: true, childList: true,
subtree: true subtree: true
}) })
}
// This is because most pages use this and by having it all in it's own chunk it makes webpack
// split out a ton of stuff (like @instructure/ui-view) into multiple chunks because its chunking
// algorithm decides that because that chunk would either be too small or it would cause more than
// our maxAsyncRequests it should concat it into mutlple parents.
require.include('./features/navigation_header')
if (!window.bundles) window.bundles = []
window.bundles.push = loadBundle
// If you add to this be sure there is support for it in the intl-polyfills package!
const intlSubsystemsInUse = ['DateTimeFormat', 'RelativeTimeFormat', 'NumberFormat']
// Do we have native support in the given Intl subsystem for one of the current
// locale fallbacks?
function noNativeSupport(sys) {
const locales = [...ENV.LOCALES]
// 'en' is the final fallback, don't settle for that unless it's the only
// available locale, in which case there is obviously native support.
if (locales.length < 1 || (locales.length === 1 && locales[0] === 'en')) return false
if (locales.slice(-1)[0] === 'en') locales.pop()
for (const locale of locales) {
const native = Intl[sys].supportedLocalesOf([locale])
if (native.length > 0) return false
}
return true
}
async function maybePolyfillLocaleThenGo() {
// If any Intl subsystem has no native support for the current locale, start
// trying to polyfill that locale from @formatjs. Note that this (possibly slow)
// process only executes at all if polyfilling was detected to be necessary.
if (intlSubsystemsInUse.some(noNativeSupport)) {
/* eslint-disable no-console */
try {
const im = await import('intl-polyfills')
const result = await im.loadAllLocalePolyfills(ENV.LOCALES, intlSubsystemsInUse)
result.forEach(r => {
if (r.error)
console.error(`${r.subsys} polyfill for locale "${r.locale}" failed: ${r.error}`)
if (r.source === 'polyfill')
console.info(`${r.subsys} polyfilled "${r.loaded}" for locale "${r.locale}"`)
})
} catch (e) {
console.error(`Locale polyfill load failed: ${e.message}`)
}
/* eslint-enable no-console */
}
// After possible polyfilling has completed, now we can start evaluating any
// queueud JS bundles, arrange for tasks to run after the document is fully ready,
// and advance the readiness state.
advanceReadiness('localePolyfills')
window.bundles.forEach(loadBundle)
ready(afterDocumentReady)
}
maybePolyfillLocaleThenGo().catch(e =>
// eslint-disable-next-line no-console
console.error(`Front-end bundles did not successfully start! (${e.message})`)
)
if (ENV.csp)
// eslint-disable-next-line promise/catch-or-return
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').then(({default: helpDialog}) => helpDialog.open())
})
// Backbone routes
$('body').on(
'click',
'[data-pushstate]',
preventDefault(function () {
Backbone.history.navigate($(this).attr('href'), true)
})
)
if (
window.ENV.NEW_USER_TUTORIALS &&
window.ENV.NEW_USER_TUTORIALS.is_enabled &&
window.ENV.context_asset_string &&
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()
})
}
;(window.requestIdleCallback || window.setTimeout)(() => {
// eslint-disable-next-line promise/catch-or-return
import('./boot/initializers/runOnEveryPageButDontBlockAnythingElse').then(() =>
advanceReadiness('asyncInitializers')
)
}) })

View File

@ -122,12 +122,22 @@ describe('utcDateOffset::', () => {
let testDate let testDate
it('detects the next UTC day', () => { it('detects the next UTC day', () => {
testDate = new Date('2021-04-02T02:35:00.000Z') // April 2nd in UTC in the US testDate = new Date('2021-04-02T02:35:00.000Z') // ... is April 1st in the US
expect(utcDateOffset(testDate, americaTZ)).toBe(1) expect(utcDateOffset(testDate, americaTZ)).toBe(1)
}) })
it('detects the previous UTC day', () => { it('detects the previous UTC day', () => {
testDate = new Date('2021-04-01T18:45:00.000Z') // March 31st in UTC in Australia (4:15am local) testDate = new Date('2021-04-01T18:45:00.000Z') // ... is 4:15am April 2nd in Australia
expect(utcDateOffset(testDate, australiaTZ)).toBe(-1)
})
it('still works if the next UTC day is also the next month', () => {
testDate = new Date('2022-04-01T01:00:00.000Z') // ... is March 31st in the US
expect(utcDateOffset(testDate, americaTZ)).toBe(1)
})
it('still works if the previous UTC day is also the previous month', () => {
testDate = new Date('2022-03-31T22:00:00.000Z') // ... is April 1st in Australia
expect(utcDateOffset(testDate, australiaTZ)).toBe(-1) expect(utcDateOffset(testDate, australiaTZ)).toBe(-1)
}) })

View File

@ -21,15 +21,32 @@ const MINS = 60 * 1000
function formatObject(tz) { function formatObject(tz) {
return { return {
year: 'numeric', year: 'numeric',
month: 'long', month: '2-digit',
day: 'numeric', day: '2-digit',
hour: 'numeric', hour: '2-digit',
minute: 'numeric', minute: '2-digit',
second: 'numeric', second: '2-digit',
hourCycle: 'h23',
timeZone: tz timeZone: tz
} }
} }
// PRIVATE: Takes a Date object and a timezone, and constructs a string that
// represents the given Date, represented in the given timezone, that we
// know new Date() will be able to handle, of the form: YYYY-MM-DDThh:mm:ss
function parseableDateString(date, tz) {
// in case Intl has been polyfilled, we'll need the native one here because
// @formatjs does not provide formatToParts()
const DTF = Intl.NativeDateTimeFormat || Intl.DateTimeFormat
const fmtr = new DTF('en-US', formatObject(tz))
const r = fmtr
.formatToParts(date)
.reduce((acc, obj) => Object.assign(acc, {[obj.type]: obj.value}), {})
let iso = `${r.year}-${r.month}-${r.day}T${r.hour}:${r.minute}:${r.second}`
if (iso.length < 19) iso = '0' + iso // in case of a year before 1000
return iso
}
// //
// Takes a Date object and an object with two optional properties, // Takes a Date object and an object with two optional properties,
// originTZ: an origin timezone // originTZ: an origin timezone
@ -57,8 +74,8 @@ function utcTimeOffset(date, hereTZ = null) {
return -date.getTimezoneOffset() * MINS return -date.getTimezoneOffset() * MINS
} }
const jsDate = date instanceof Date ? date : new Date(date) const jsDate = date instanceof Date ? date : new Date(date)
const hereDate = new Date(jsDate.toLocaleString('en-US', formatObject(hereTZ))) const hereDate = new Date(parseableDateString(jsDate, hereTZ))
const utcDate = new Date(jsDate.toLocaleString('en-US', formatObject('Etc/UTC'))) const utcDate = new Date(parseableDateString(jsDate, 'Etc/UTC'))
return hereDate.getTime() - utcDate.getTime() return hereDate.getTime() - utcDate.getTime()
} }
@ -68,9 +85,17 @@ function utcTimeOffset(date, hereTZ = null) {
// //
function utcDateOffset(date, hereTZ) { function utcDateOffset(date, hereTZ) {
const jsDate = date instanceof Date ? date : new Date(date) const jsDate = date instanceof Date ? date : new Date(date)
const here = jsDate.toLocaleString('en-US', {day: 'numeric', timeZone: hereTZ}) const dayOfMonth = timeZone => {
const utc = jsDate.toLocaleString('en-US', {day: 'numeric', timeZone: 'Etc/UTC'}) const DTF = Intl.NativeDateTimeFormat || Intl.DateTimeFormat
return parseInt(utc, 10) - parseInt(here, 10) const fmtr = new DTF('en-US', {day: 'numeric', timeZone})
return parseInt(fmtr.format(jsDate), 10)
}
const here = dayOfMonth(hereTZ)
const utc = dayOfMonth('Etc/UTC')
let diff = utc - here
if (diff < -1) diff = 1 // crossed a month going forwards
if (diff > 1) diff = -1 // crossed a month going backwards
return diff
} }
export {changeTimezone, utcTimeOffset, utcDateOffset} export {changeTimezone, utcTimeOffset, utcDateOffset}

View File

@ -1389,35 +1389,46 @@
minimatch "^3.0.4" minimatch "^3.0.4"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@formatjs/ecma402-abstract@1.9.4": "@formatjs/ecma402-abstract@1.11.2":
version "1.9.4" version "1.11.2"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.4.tgz#797ae6c407fb5a0d09023a60c86f19aca1958c5e" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.2.tgz#7f01595e6985a28983aae26bede9b78b273fee3d"
integrity sha512-ePJXI7tWC9PBxQxS7jtbkCLGVmpC8MH8n9Yjmg8dsh9wXK9svu7nAbq76Oiu5Zb+5GVkLkeTVerlSvHCbNImlA== integrity sha512-qDgOL0vtfJ51cc0pRbFB/oXc4qDbamG22Z6h/QWy6FBxaQgppiy8JF0iYbmNO35cC8r88bQGsgfd/eM6/eTEQQ==
dependencies:
"@formatjs/intl-localematcher" "0.2.23"
tslib "^2.1.0"
"@formatjs/intl-datetimeformat@^4.5":
version "4.5.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-4.5.1.tgz#588d5d9875bd19417873fdc67112456c3b28b2ff"
integrity sha512-4Ncg6bC5X8QyA4jsyHCGyO84aLulqpKXbxrvsPF1Y4deMFcYBVzg43fT/Dbp6ZqfdViGJ3K/RTBD8XlAD8evIg==
dependencies:
"@formatjs/ecma402-abstract" "1.11.2"
"@formatjs/intl-localematcher" "0.2.23"
tslib "^2.1.0"
"@formatjs/intl-localematcher@0.2.23":
version "0.2.23"
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.23.tgz#5a0b1d81df1f392ecf37e556ca7040a7ec9f72e8"
integrity sha512-oCe2TOciTtB1bEbJ85EvYrXQxD0epusmVJfJ7AduO0tlbXP42CmDIYIH2CZ+kP2GE+PTLQD1Hbt9kpOpl939MQ==
dependencies: dependencies:
tslib "^2.1.0" tslib "^2.1.0"
"@formatjs/intl-datetimeformat@^4": "@formatjs/intl-numberformat@^7.4":
version "4.1.6" version "7.4.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-4.1.6.tgz#ce30d3907dfb229c79e023c03171da09b2611e36" resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-7.4.1.tgz#41b452505e4e3864ddb5e5c5e176dd467bc00abd"
integrity sha512-o4M/RzBNAC/LmOwAd3W/3bsCT7xl9luddNdX9x1uZroS/8Wx6M8sE/KMNDCSZogZtmMasCBvpNmuQoeMyBBASg== integrity sha512-9F+n4fO/u9p5bvUHDZK5pvuVqFKjrWRe3SVf5D216IZcjGO8veeRPDPjja+YoRpNDwR0ITIyMlV+IdUHXmfwvA==
dependencies: dependencies:
"@formatjs/ecma402-abstract" "1.9.4" "@formatjs/ecma402-abstract" "1.11.2"
"@formatjs/intl-localematcher" "0.2.23"
tslib "^2.1.0" tslib "^2.1.0"
"@formatjs/intl-numberformat@^7": "@formatjs/intl-relativetimeformat@^9.5":
version "7.1.5" version "9.5.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-7.1.5.tgz#8d1df875f7dfbd2ed8e1326fddb5f4dad3c66d11" resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-9.5.1.tgz#d00f2bf9cf9840da3c0e8b27a2ea26e12ecc376a"
integrity sha512-1U6IEp/1vf2XzfDjLWqJuQufRZY3/F9UfOCAVp14eYVb/+gZmG4Q3heJQZXOAKnnQl3YGG73Ez7UgFVo/rowGQ== integrity sha512-A4kL6tTjcvVgyKH33BREBWBW2nPgTJSkhjKcTGfNHdmvWN8WAbDovRVOUZ6UmufRW7ZZfoDA8QT7sEOKuxKqyg==
dependencies: dependencies:
"@formatjs/ecma402-abstract" "1.9.4" "@formatjs/ecma402-abstract" "1.11.2"
tslib "^2.1.0" "@formatjs/intl-localematcher" "0.2.23"
"@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" tslib "^2.1.0"
"@graphql-typed-document-node/core@^3.0.0": "@graphql-typed-document-node/core@^3.0.0":