diff --git a/README.md b/README.md index 4f89a8a3..37d3a01c 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,11 @@ bundle. For example: const {getLocale} = {getLocale: () => 'es-419'}; ``` -### `getLocale(): string` +### `getLocale() => string` Return the active locale code. -### `setLocale(locale: string): Promise` +### `setLocale(locale: string) => Promise` Set the active locale code, and begin loading templates for that locale using the `loadLocale` function that was passed to `configureLocalization`. Returns a @@ -156,21 +156,74 @@ template for each emitted locale. For example: html`Hola ${getUsername()}!`; ``` -### `LOCALE_CHANGED_EVENT: string` +### `LOCALE_STATUS_EVENT` -Whenever the locale changes and templates have finished loading, an event by -this name (`"lit-localize-locale-changed"`) is dispatched to `window`. +Name of the [`lit-localize-status` event](#lit-localize-status-event). + +## `lit-localize-status` event + +In runtime mode, whenever a locale change starts, finishes successfully, or +fails, lit-localize will dispatch a `lit-localize-status` event to `window`. You can listen for this event to know when your application should be re-rendered following a locale change. See also the [`Localized`](#localized-mixin) mixin, which automatically re-renders `LitElement` classes using this event. -```typescript -import {LOCALE_CHANGED_EVENT} from 'lit-localize'; +### Event types + +The `detail.status` string property tells you what kind of status change has occured, +and can be one of: `loading`, `ready`, or `error`: + +#### `loading` + +A new locale has started to load. The `detail` object also contains: + +- `loadingLocale: string`: Code of the locale that has started loading. + +A `loading` status can be followed by a `ready`, `error`, or `loading` status. +It will be followed by another `loading` status in the case that a second locale +was requested before the first one finished loading. + +#### `ready` + +A new locale has successfully loaded and is ready for rendering. The `detail` object also contains: -window.addEventListener(LOCALE_CHANGED_EVENT, () => { - renderApplication(); +- `readyLocale: string`: Code of the locale that has successfully loaded. + +A `ready` status can be followed only by a `loading` status. + +#### `error` + +A new locale failed to load. The `detail` object also contains the following +properties: + +- `errorLocale: string`: Code of the locale that failed to load. +- `errorMessage: string`: Error message from locale load failure. + +An `error` status can be followed only by a `loading` status. + +### Event example + +```typescript +// Show/hide a progress indicator whenever a new locale is loading, +// and re-render the application every time a new locale successfully loads. +window.addEventListener('lit-localize-status', (event) => { + const spinner = document.querySelector('#spinner'); + if (event.detail.status === 'loading') { + console.log(`Loading new locale: ${event.detail.loadingLocale}`); + spinner.removeAttribute('hidden'); + } else if (event.detail.status === 'ready') { + console.log(`Loaded new locale: ${event.detail.readyLocale}`); + spinner.addAttribute('hidden'); + renderApplication(); + } else if (event.detail.status === 'error') { + console.error( + `Error loading locale ${event.detail.errorLocale}: ` + + event.detail.errorMessage + ); + spinner.addAttribute('hidden'); + } }); ``` diff --git a/src/outputters/transform.ts b/src/outputters/transform.ts index 2d2e4ed6..49ea9632 100644 --- a/src/outputters/transform.ts +++ b/src/outputters/transform.ts @@ -114,8 +114,13 @@ class Transformer { } // import ... from 'lit-localize' -> (removed) - if (this.isLitLocalizeImport(node)) { - return undefined; + if (ts.isImportDeclaration(node)) { + const moduleSymbol = this.typeChecker.getSymbolAtLocation( + node.moduleSpecifier + ); + if (moduleSymbol && this.isLitLocalizeModule(moduleSymbol)) { + return undefined; + } } if (ts.isCallExpression(node)) { @@ -172,17 +177,41 @@ class Transformer { } } - // LOCALE_CHANGED_EVENT -> "lit-localize-locale-changed" + // LOCALE_STATUS_EVENT -> "lit-localize-status" // - // This is slightly odd, but by replacing the LOCALE_CHANGED_EVENT const - // with its static string value, we don't have to be smart about deciding - // when to remove the 'lit-localize' module import, since we can assume that - // everything it exports will be transformed out. - if ( - ts.isIdentifier(node) && - this.typeHasProperty(node, '_LIT_LOCALIZE_LOCALE_CHANGED_EVENT_') - ) { - return ts.createStringLiteral('lit-localize-locale-changed'); + // We want to replace this imported string constant with its static value so + // that we can always safely remove the 'lit-localize' module import. + // + // TODO(aomarks) Maybe we should error here instead, since lit-localize + // won't fire any of these events in transform mode? But I'm still thinking + // about the use case of an app that can run in either runtime or transform + // mode without code changes (e.g. runtime for dev, transform for + // production)... + // + // We can't tag this string const with a special property like we do with + // our exported functions, because doing so breaks lookups into + // `WindowEventMap`. So we instead identify the symbol by name, and check + // that it was declared in the lit-localize module. + let eventSymbol = this.typeChecker.getSymbolAtLocation(node); + if (eventSymbol && eventSymbol.name === 'LOCALE_STATUS_EVENT') { + if (eventSymbol.flags & ts.SymbolFlags.Alias) { + // Symbols will be aliased in the case of + // `import {LOCALE_STATUS_EVENT} ...` + // but not in the case of `import * as ...`. + eventSymbol = this.typeChecker.getAliasedSymbol(eventSymbol); + } + for (const decl of eventSymbol.declarations) { + let sourceFile: ts.Node = decl; + while (!ts.isSourceFile(sourceFile)) { + sourceFile = sourceFile.parent; + } + const sourceFileSymbol = this.typeChecker.getSymbolAtLocation( + sourceFile + ); + if (sourceFileSymbol && this.isLitLocalizeModule(sourceFileSymbol)) { + return ts.createStringLiteral('lit-localize-status'); + } + } } return ts.visitEachChild(node, this.boundVisitNode, this.context); @@ -410,17 +439,11 @@ class Transformer { } /** - * Return whether the given node is an import for the lit-localize main - * module, or the localized-element module. + * Return whether the given symbol looks like one of the lit-localize modules + * (because it exports one of the special tagged functions). */ - isLitLocalizeImport(node: ts.Node): node is ts.ImportDeclaration { - if (!ts.isImportDeclaration(node)) { - return false; - } - const moduleSymbol = this.typeChecker.getSymbolAtLocation( - node.moduleSpecifier - ); - if (!moduleSymbol || !moduleSymbol.exports) { + isLitLocalizeModule(moduleSymbol: ts.Symbol): boolean { + if (!moduleSymbol.exports) { return false; } const exports = moduleSymbol.exports.values(); @@ -429,7 +452,13 @@ class Transformer { }) { const type = this.typeChecker.getTypeAtLocation(xport.valueDeclaration); const props = this.typeChecker.getPropertiesOfType(type); - if (props.some((prop) => prop.escapedName === '_LIT_LOCALIZE_MSG_')) { + if ( + props.some( + (prop) => + prop.escapedName === '_LIT_LOCALIZE_MSG_' || + prop.escapedName === '_LIT_LOCALIZE_LOCALIZED_' + ) + ) { return true; } } diff --git a/src/tests/transform.unit.test.ts b/src/tests/transform.unit.test.ts index 9a92bf68..f49bb98b 100644 --- a/src/tests/transform.unit.test.ts +++ b/src/tests/transform.unit.test.ts @@ -366,12 +366,48 @@ test('configureLocalization() throws', (t) => { ); }); -test('LOCALE_CHANGED_EVENT => "lit-localize-locale-changed"', (t) => { +test('LOCALE_STATUS_EVENT => "lit-localize-status"', (t) => { checkTransform( t, - `import {LOCALE_CHANGED_EVENT} from './lib_client/index.js'; - window.addEventListener(LOCALE_CHANGED_EVENT, () => console.log('ok'));`, - `window.addEventListener('lit-localize-locale-changed', () => console.log('ok'));` + `import {LOCALE_STATUS_EVENT} from './lib_client/index.js'; + window.addEventListener(LOCALE_STATUS_EVENT, () => console.log('ok'));`, + `window.addEventListener('lit-localize-status', () => console.log('ok'));` + ); +}); + +test('litLocalize.LOCALE_STATUS_EVENT => "lit-localize-status"', (t) => { + checkTransform( + t, + `import * as litLocalize from './lib_client/index.js'; + window.addEventListener(litLocalize.LOCALE_STATUS_EVENT, () => console.log('ok'));`, + `window.addEventListener('lit-localize-status', () => console.log('ok'));` + ); +}); + +test('re-assigned LOCALE_STATUS_EVENT', (t) => { + checkTransform( + t, + `import {LOCALE_STATUS_EVENT} from './lib_client/index.js'; + const event = LOCALE_STATUS_EVENT; + window.addEventListener(event, () => console.log('ok'));`, + `const event = 'lit-localize-status'; + window.addEventListener(event, () => console.log('ok'));` + ); +}); + +test('different LOCALE_STATUS_EVENT variable unchanged', (t) => { + checkTransform( + t, + `const LOCALE_STATUS_EVENT = "x";`, + `const LOCALE_STATUS_EVENT = "x";` + ); +}); + +test('different variable cast to "lit-localie-status" unchanged', (t) => { + checkTransform( + t, + `const x = "x" as "lit-localize-status";`, + `const x = "x";` ); }); @@ -380,6 +416,7 @@ test('Localized(LitElement) -> LitElement', (t) => { t, `import {LitElement, html} from 'lit-element'; import {Localized} from './lib_client/localized-element.js'; + import {msg} from './lib_client/index.js'; class MyElement extends Localized(LitElement) { render() { return html\`\${msg('greeting', 'Hello World!')}\`; @@ -390,6 +427,7 @@ test('Localized(LitElement) -> LitElement', (t) => { render() { return html\`Hello World!\`; } - }` + }`, + {autoImport: false} ); }); diff --git a/src_client/index.ts b/src_client/index.ts index 3c4a8707..d9ccb0e3 100644 --- a/src_client/index.ts +++ b/src_client/index.ts @@ -69,6 +69,77 @@ export interface LocaleModule { templates: TemplateMap; } +/** + * Name of the event dispatched to `window` whenever a locale change starts, + * finishes successfully, or fails. Only relevant to runtime mode. + * + * The `detail` of this event is an object with a `status` string that can be: + * "loading", "ready", or "error", along with the relevant locale code, and + * error message if applicable. + * + * You can listen for this event to know when your application should be + * re-rendered following a locale change. See also the Localized mixin, which + * automatically re-renders LitElement classes using this event. + */ +export const LOCALE_STATUS_EVENT = 'lit-localize-status'; + +declare global { + interface WindowEventMap { + [LOCALE_STATUS_EVENT]: CustomEvent; + } +} + +/** + * The possible details of the "lit-localize-status" event. + */ +export type LocaleStatusEventDetail = LocaleLoading | LocaleReady | LocaleError; + +/** + * Detail of the "lit-localize-status" event when a new locale has started to + * load. + * + * A "loading" status can be followed by [1] another "loading" status (in the + * case that a second locale is requested before the first one completed), [2] a + * "ready" status, or [3] an "error" status. + */ +export interface LocaleLoading { + status: 'loading'; + /** Code of the locale that has started loading. */ + loadingLocale: string; +} + +/** + * Detail of the "lit-localize-status" event when a new locale has successfully + * loaded and is ready for rendering. + * + * A "ready" status can be followed only by a "loading" status. + */ +export interface LocaleReady { + status: 'ready'; + /** Code of the locale that has successfully loaded. */ + readyLocale: string; +} + +/** + * Detail of the "lit-localize-status" event when a new locale failed to load. + * + * An "error" status can be followed only by a "loading" status. + */ +export interface LocaleError { + status: 'error'; + /** Code of the locale that failed to load. */ + errorLocale: string; + /** Error message from locale load failure. */ + errorMessage: string; +} + +/** + * Dispatch a "lit-localize-status" event to `window` with the given detail. + */ +function dispatchStatusEvent(detail: LocaleStatusEventDetail) { + window.dispatchEvent(new CustomEvent(LOCALE_STATUS_EVENT, {detail})); +} + class Deferred { readonly promise: Promise; private _resolve!: (value: T) => void; @@ -103,8 +174,6 @@ let templates: TemplateMap | undefined; let loading = new Deferred(); /** - * Set runtime configuration parameters for lit-localize. This function must be - * called before using any other lit-localize function. * Set configuration parameters for lit-localize when in runtime mode. Returns * an object with functions: * @@ -150,18 +219,6 @@ export const configureTransformLocalization: (( return {getLocale}; }; -/** - * Whenever the locale changes and templates have finished loading, an event by - * this name ("lit-localize-locale-changed") is dispatched to window. - * - * You can listen for this event to know when your application should be - * re-rendered following a locale change. See also the Localized mixin, which - * automatically re-renders LitElement classes using this event. - */ -export const LOCALE_CHANGED_EVENT: string & { - _LIT_LOCALIZE_LOCALE_CHANGED_EVENT_?: never; -} = 'lit-localize-locale-changed'; - /** * Return the active locale code. */ @@ -201,12 +258,13 @@ const setLocale: ((newLocale: string) => void) & { if (loading.settled) { loading = new Deferred(); } + dispatchStatusEvent({status: 'loading', loadingLocale: newLocale}); if (newLocale === sourceLocale) { activeLocale = newLocale; loadingLocale = undefined; templates = undefined; loading.resolve(); - window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); + dispatchStatusEvent({status: 'ready', readyLocale: newLocale}); } else { loadLocale(newLocale).then( (mod) => { @@ -215,7 +273,7 @@ const setLocale: ((newLocale: string) => void) & { loadingLocale = undefined; templates = mod.templates; loading.resolve(); - window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); + dispatchStatusEvent({status: 'ready', readyLocale: newLocale}); } // Else another locale was requested in the meantime. Don't resolve or // reject, because the newer load call is going to use the same promise. @@ -225,6 +283,11 @@ const setLocale: ((newLocale: string) => void) & { (err) => { if (newLocale === loadingLocale) { loading.reject(err); + dispatchStatusEvent({ + status: 'error', + errorLocale: newLocale, + errorMessage: err.toString(), + }); } } ); diff --git a/src_client/localized-element.ts b/src_client/localized-element.ts index 1ebb25ae..92ca04bb 100644 --- a/src_client/localized-element.ts +++ b/src_client/localized-element.ts @@ -10,7 +10,7 @@ */ import {LitElement} from 'lit-element'; -import {LOCALE_CHANGED_EVENT} from './index.js'; +import {LOCALE_STATUS_EVENT} from './index.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Constructor = new (...args: any[]) => T; @@ -39,19 +39,28 @@ type Constructor = new (...args: any[]) => T; */ function _Localized>(Base: T): T { class Localized extends Base { - private __boundRequestUpdate = () => this.requestUpdate(); + private readonly __litLocalizeEventHandler = ( + event: WindowEventMap[typeof LOCALE_STATUS_EVENT] + ) => { + if (event.detail.status === 'ready') { + this.requestUpdate(); + } + }; connectedCallback() { super.connectedCallback(); - window.addEventListener(LOCALE_CHANGED_EVENT, this.__boundRequestUpdate); + window.addEventListener( + LOCALE_STATUS_EVENT, + this.__litLocalizeEventHandler + ); } disconnectedCallback() { - super.disconnectedCallback(); window.removeEventListener( - LOCALE_CHANGED_EVENT, - this.__boundRequestUpdate + LOCALE_STATUS_EVENT, + this.__litLocalizeEventHandler ); + super.disconnectedCallback(); } } diff --git a/testdata/transform/goldens/foo.ts b/testdata/transform/goldens/foo.ts index 15ebd77d..7a7ac604 100644 --- a/testdata/transform/goldens/foo.ts +++ b/testdata/transform/goldens/foo.ts @@ -2,12 +2,17 @@ import {LitElement, html} from 'lit-element'; import { msg, configureTransformLocalization, + LOCALE_STATUS_EVENT, } from '../../../lib_client/index.js'; import {Localized} from '../../../lib_client/localized-element.js'; const {getLocale} = configureTransformLocalization({sourceLocale: 'en'}); console.log(`Locale is ${getLocale()}`); +window.addEventListener(LOCALE_STATUS_EVENT, (event) => { + console.log(event.detail.status); +}); + msg('string', 'Hello World!'); msg('lit', html`Hello World!`); diff --git a/testdata/transform/goldens/tsout/en/foo.js b/testdata/transform/goldens/tsout/en/foo.js index ebe946db..d2a99887 100644 --- a/testdata/transform/goldens/tsout/en/foo.js +++ b/testdata/transform/goldens/tsout/en/foo.js @@ -1,7 +1,9 @@ import {LitElement, html} from 'lit-element'; -import {Localized} from '../../../lib_client/localized-element.js'; const {getLocale} = {getLocale: () => 'en'}; console.log(`Locale is ${getLocale()}`); +window.addEventListener('lit-localize-status', (event) => { + console.log(event.detail.status); +}); ('Hello World!'); html`Hello World!`; `Hello World!`; diff --git a/testdata/transform/goldens/tsout/es-419/foo.js b/testdata/transform/goldens/tsout/es-419/foo.js index 7a235bd5..08e425df 100644 --- a/testdata/transform/goldens/tsout/es-419/foo.js +++ b/testdata/transform/goldens/tsout/es-419/foo.js @@ -1,7 +1,9 @@ import {LitElement, html} from 'lit-element'; -import {Localized} from '../../../lib_client/localized-element.js'; const {getLocale} = {getLocale: () => 'es-419'}; console.log(`Locale is ${getLocale()}`); +window.addEventListener('lit-localize-status', (event) => { + console.log(event.detail.status); +}); `Hola Mundo!`; html`Hola Mundo!`; `Hola World!`; diff --git a/testdata/transform/goldens/tsout/zh_CN/foo.js b/testdata/transform/goldens/tsout/zh_CN/foo.js index bbbead47..00f3f0c0 100644 --- a/testdata/transform/goldens/tsout/zh_CN/foo.js +++ b/testdata/transform/goldens/tsout/zh_CN/foo.js @@ -1,7 +1,9 @@ import {LitElement, html} from 'lit-element'; -import {Localized} from '../../../lib_client/localized-element.js'; const {getLocale} = {getLocale: () => 'zh_CN'}; console.log(`Locale is ${getLocale()}`); +window.addEventListener('lit-localize-status', (event) => { + console.log(event.detail.status); +}); `\u4F60\u597D\uFF0C\u4E16\u754C\uFF01`; html`你好, 世界!`; `Hello World!`; diff --git a/testdata/transform/input/foo.ts b/testdata/transform/input/foo.ts index 15ebd77d..7a7ac604 100644 --- a/testdata/transform/input/foo.ts +++ b/testdata/transform/input/foo.ts @@ -2,12 +2,17 @@ import {LitElement, html} from 'lit-element'; import { msg, configureTransformLocalization, + LOCALE_STATUS_EVENT, } from '../../../lib_client/index.js'; import {Localized} from '../../../lib_client/localized-element.js'; const {getLocale} = configureTransformLocalization({sourceLocale: 'en'}); console.log(`Locale is ${getLocale()}`); +window.addEventListener(LOCALE_STATUS_EVENT, (event) => { + console.log(event.detail.status); +}); + msg('string', 'Hello World!'); msg('lit', html`Hello World!`);