diff --git a/fluent-react/examples/fallback-async/package.json b/fluent-react/examples/fallback-async/package.json new file mode 100644 index 000000000..357a34ba7 --- /dev/null +++ b/fluent-react/examples/fallback-async/package.json @@ -0,0 +1,20 @@ +{ + "name": "fluent-react-example-fallback-async", + "version": "0.1.0", + "private": true, + "devDependencies": { + "react-scripts": "1.1.0" + }, + "dependencies": { + "babel-polyfill": "^6.26.0", + "fluent": "file:../../../fluent", + "fluent-intl-polyfill": "file:../../../fluent-intl-polyfill", + "fluent-react": "file:../../../fluent-react", + "react": "^16.2.0", + "react-dom": "^16.2.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + } +} diff --git a/fluent-react/examples/fallback-async/public/index.html b/fluent-react/examples/fallback-async/public/index.html new file mode 100644 index 000000000..0e506c233 --- /dev/null +++ b/fluent-react/examples/fallback-async/public/index.html @@ -0,0 +1,11 @@ + + + + + + Fallback Async - a fluent-react example + + +
+ + diff --git a/fluent-react/examples/fallback-async/src/App.js b/fluent-react/examples/fallback-async/src/App.js new file mode 100644 index 000000000..16fcec3ea --- /dev/null +++ b/fluent-react/examples/fallback-async/src/App.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { Localized } from 'fluent-react/compat'; + +export default function App() { + return ( +
+

+ This example is hardcoded to use ['pl', 'en-US']. +

+ + +

Foo

+
+ + +

Bar

+
+ + +

Baz is missing from all locales

+
+ + +

{'Qux is like { $baz }: missing from all locales.'}

+
+
+ ); +} diff --git a/fluent-react/examples/fallback-async/src/index.js b/fluent-react/examples/fallback-async/src/index.js new file mode 100644 index 000000000..63c002097 --- /dev/null +++ b/fluent-react/examples/fallback-async/src/index.js @@ -0,0 +1,14 @@ +import 'babel-polyfill'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { LocalizationProvider } from 'fluent-react/compat'; + +import { generateMessages } from './l10n'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/fluent-react/examples/fallback-async/src/l10n.js b/fluent-react/examples/fallback-async/src/l10n.js new file mode 100644 index 000000000..769cb7071 --- /dev/null +++ b/fluent-react/examples/fallback-async/src/l10n.js @@ -0,0 +1,45 @@ +import 'fluent-intl-polyfill/compat'; +import { MessageContext } from 'fluent/compat'; + +const MESSAGES_ALL = { + 'pl': ` +foo = Foo po polsku + `, + 'en-US': ` +foo = Foo in English +bar = Bar in English + `, +}; + + +// create-react-app is rather outdated at this point when it comes to modern JS +// support. It's not configured to understand async generators. The function +// below is equivalent to the following generator function: +// +// export async function* generateMessages() { +// for (const locale of ['pl', 'en-US']) { +// const cx = new MessageContext(locale); +// cx.addMessages(MESSAGES_ALL[locale]); +// yield cx; +// } +// } + +export function generateMessages() { + const locales = ["pl", "en-US"]; + return { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + const locale = locales.shift(); + + if (locale === undefined) { + return {value: undefined, done: true}; + } + + const cx = new MessageContext(locale); + cx.addMessages(MESSAGES_ALL[locale]); + return {value: cx, done: false}; + } + }; +} diff --git a/fluent-react/makefile b/fluent-react/makefile index c0f6470a3..78f085298 100644 --- a/fluent-react/makefile +++ b/fluent-react/makefile @@ -1,6 +1,6 @@ PACKAGE := fluent-react GLOBAL := FluentReact -DEPS := fluent:Fluent,react:React,prop-types:PropTypes +DEPS := fluent:Fluent,cached-iterable:CachedIterable,react:React,prop-types:PropTypes include ../common.mk diff --git a/fluent-react/src/cached_async_iterable.js b/fluent-react/src/cached_async_iterable.js new file mode 100644 index 000000000..381515eb7 --- /dev/null +++ b/fluent-react/src/cached_async_iterable.js @@ -0,0 +1,102 @@ +/* + * CachedAsyncIterable caches the elements yielded by an async iterable. + * + * It can be used to iterate over an iterable many times without depleting the + * iterable. + */ +export default class CachedAsyncIterable { + /** + * Create an `CachedAsyncIterable` instance. + * + * @param {Iterable} iterable + * @returns {CachedAsyncIterable} + */ + constructor(iterable) { + if (Symbol.asyncIterator in Object(iterable)) { + this.iterator = iterable[Symbol.asyncIterator](); + } else if (Symbol.iterator in Object(iterable)) { + this.iterator = iterable[Symbol.iterator](); + } else { + throw new TypeError("Argument must implement the iteration protocol."); + } + + this.seen = []; + } + + /** + * Create an `CachedAsyncIterable` instance from an iterable or, if + * another instance of `CachedAsyncIterable` is passed, return it + * without any modifications. + * + * @param {Iterable} iterable + * @returns {CachedAsyncIterable} + */ + static from(iterable) { + if (iterable instanceof CachedAsyncIterable) { + return iterable; + } + + return new CachedAsyncIterable(iterable); + } + + /** + * Synchronous iterator over the cached elements. + * + * Return a generator object implementing the iterator protocol over the + * cached elements of the original (async or sync) iterable. + */ + [Symbol.iterator]() { + const {seen} = this; + let cur = 0; + + return { + next() { + if (seen.length === cur) { + return {value: undefined, done: true}; + } + return seen[cur++]; + } + }; + } + + /** + * Asynchronous iterator caching the yielded elements. + * + * Elements yielded by the original iterable will be cached and available + * synchronously. Returns an async generator object implementing the + * iterator protocol over the elements of the original (async or sync) + * iterable. + */ + [Symbol.asyncIterator]() { + const { seen, iterator } = this; + let cur = 0; + + return { + async next() { + if (seen.length <= cur) { + seen.push(await iterator.next()); + } + return seen[cur++]; + } + }; + } + + /** + * This method allows user to consume the next element from the iterator + * into the cache. + * + * @param {number} count - number of elements to consume + */ + async touchNext(count = 1) { + const { seen, iterator } = this; + let idx = 0; + while (idx++ < count) { + if (seen.length === 0 || seen[seen.length - 1].done === false) { + seen.push(await iterator.next()); + } + } + // Return the last cached {value, done} object to allow the calling + // code to decide if it needs to call touchNext again. + return seen[seen.length - 1]; + } +} diff --git a/fluent-react/src/localization.js b/fluent-react/src/localization.js index 70089486d..f99e608ee 100644 --- a/fluent-react/src/localization.js +++ b/fluent-react/src/localization.js @@ -1,4 +1,5 @@ -import { CachedIterable, mapContextSync } from "fluent"; +import {mapContextSync} from "fluent"; +import CachedAsyncIterable from "./cached_async_iterable"; /* * `ReactLocalization` handles translation formatting and fallback. @@ -17,8 +18,10 @@ import { CachedIterable, mapContextSync } from "fluent"; */ export default class ReactLocalization { constructor(messages) { - this.contexts = new CachedIterable(messages); + this.contexts = CachedAsyncIterable.from(messages); this.subs = new Set(); + + this.isFetching = false; } /* @@ -39,14 +42,39 @@ export default class ReactLocalization { * Set a new `messages` iterable and trigger the retranslation. */ setMessages(messages) { - this.contexts = new CachedIterable(messages); - - // Update all subscribed Localized components. - this.subs.forEach(comp => comp.relocalize()); + this.contexts = CachedAsyncIterable.from(messages); + if (this.contexts.length === 0) { + // If the iterable has an empty cache, request the first context but do + // not await it. + this.touchNext(1); + } else { + // Otherwise, let's try to use the new cached contexts. Update all + // subscribed Localized components. + this.subs.forEach(comp => comp.relocalize()); + } } getMessageContext(id) { - return mapContextSync(this.contexts, id); + const mcx = mapContextSync(this.contexts, id); + if (mcx === null && !this.isFetching) { + // Request the next context but do not await it. + this.touchNext(1); + } + return mcx; + } + + async touchNext(count = 1) { + // Fetch the first `count` contexts. + this.isFetching = true; + const last = await this.contexts.touchNext(count); + this.isFetching = false; + + if (!last.done) { + // If the iterable of contexts has not been exhausted yet, the newly + // fetched context might offer fallback translations. Update all + // subscribed Localized components. + this.subs.forEach(comp => comp.relocalize()); + } } formatCompound(mcx, msg, args) { diff --git a/fluent-react/src/provider.js b/fluent-react/src/provider.js index 53cfd8b84..cfeafffb3 100644 --- a/fluent-react/src/provider.js +++ b/fluent-react/src/provider.js @@ -30,7 +30,7 @@ export default class LocalizationProvider extends Component { throw new Error("LocalizationProvider must receive the messages prop."); } - if (!messages[Symbol.iterator]) { + if (!messages[Symbol.iterator] && !messages[Symbol.asyncIterator]) { throw new Error("The messages prop must be an iterable."); } @@ -72,6 +72,10 @@ function isIterable(props, propName, componentName) { return null; } + if (Symbol.asyncIterator in Object(prop)) { + return null; + } + return new Error( `The ${propName} prop supplied to ${componentName} must be an iterable.` ); diff --git a/fluent-react/test/localized_change_test.js b/fluent-react/test/localized_change_test.js index 9d997728b..9b8134ff2 100644 --- a/fluent-react/test/localized_change_test.js +++ b/fluent-react/test/localized_change_test.js @@ -3,12 +3,15 @@ import assert from 'assert'; import { shallow } from 'enzyme'; import { MessageContext } from '../../fluent/src'; import ReactLocalization from '../src/localization'; +import CachedAsyncIterable from '../src/cached_async_iterable'; import { Localized } from '../src/index'; -suite('Localized - change messages', function() { - test('relocalizing', function() { +suite.only('Localized - change messages', function() { + test('relocalizing', async function() { const mcx1 = new MessageContext(); - const l10n = new ReactLocalization([mcx1]); + const contexts1 = new CachedAsyncIterable([mcx1]); + await contexts1.touchNext(1); + const l10n = new ReactLocalization(contexts1); mcx1.addMessages(` foo = FOO @@ -30,7 +33,9 @@ foo = FOO foo = BAR `); - l10n.setMessages([mcx2]); + const contexts2 = new CachedAsyncIterable([mcx2]); + await contexts2.touchNext(1); + l10n.setMessages(contexts2); wrapper.update(); assert.ok(wrapper.contains( diff --git a/fluent-react/test/localized_render_test.js b/fluent-react/test/localized_render_test.js index 7ccb25e9f..a968dfb03 100644 --- a/fluent-react/test/localized_render_test.js +++ b/fluent-react/test/localized_render_test.js @@ -4,13 +4,20 @@ import sinon from 'sinon'; import { shallow } from 'enzyme'; import { MessageContext } from '../../fluent/src'; import ReactLocalization from '../src/localization'; +import CachedAsyncIterable from '../src/cached_async_iterable'; import { Localized } from '../src/index'; suite('Localized - rendering', function() { - test('render the value', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); + let mcx, l10n; + setup(async function() { + mcx = new MessageContext(); + const contexts = new CachedAsyncIterable([mcx]); + await contexts.touchNext(1); + l10n = new ReactLocalization(contexts); + }); + + test('render the value', async function() { mcx.addMessages(` foo = FOO `) @@ -28,9 +35,6 @@ foo = FOO }); test('render an allowed attribute', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .attr = ATTR @@ -49,9 +53,6 @@ foo = }); test('only render allowed attributes', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .attr1 = ATTR 1 @@ -71,9 +72,6 @@ foo = }); test('filter out forbidden attributes', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .attr = ATTR @@ -92,9 +90,6 @@ foo = }); test('filter all attributes if attrs not given', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .attr = ATTR @@ -113,9 +108,6 @@ foo = }); test('preserve existing attributes when setting new ones', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .attr = ATTR @@ -134,9 +126,6 @@ foo = }); test('overwrite existing attributes if allowed', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .existing = ATTR @@ -155,9 +144,6 @@ foo = }); test('protect existing attributes if setting is forbidden', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .existing = ATTR @@ -176,9 +162,6 @@ foo = }); test('protect existing attributes by default', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .existing = ATTR @@ -197,9 +180,6 @@ foo = }); test('preserve children when translation value is null', function() { - const mcx = new MessageContext(); - const l10n = new ReactLocalization([mcx]); - mcx.addMessages(` foo = .title = TITLE @@ -223,9 +203,7 @@ foo = test('$arg is passed to format the value', function() { - const mcx = new MessageContext(); const format = sinon.spy(mcx, 'format'); - const l10n = new ReactLocalization([mcx]); mcx.addMessages(` foo = { $arg } @@ -243,9 +221,7 @@ foo = { $arg } }); test('$arg is passed to format the attributes', function() { - const mcx = new MessageContext(); const format = sinon.spy(mcx, 'format'); - const l10n = new ReactLocalization([mcx]); mcx.addMessages(` foo = { $arg }