From 611de84dbb0114d3a910f676a53d29c9e9933dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Thu, 7 Jun 2018 19:42:08 +0200 Subject: [PATCH 1/2] WIP ReactLocalization: Accept async iterables of contexts --- .../examples/fallback-async/package.json | 20 +++++ .../examples/fallback-async/public/index.html | 11 +++ .../examples/fallback-async/src/App.js | 28 ++++++ .../examples/fallback-async/src/index.js | 14 +++ .../examples/fallback-async/src/l10n.js | 45 ++++++++++ fluent-react/makefile | 2 +- fluent-react/src/cached_async_iterable.js | 86 +++++++++++++++++++ fluent-react/src/localization.js | 35 ++++++-- fluent-react/src/provider.js | 6 +- 9 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 fluent-react/examples/fallback-async/package.json create mode 100644 fluent-react/examples/fallback-async/public/index.html create mode 100644 fluent-react/examples/fallback-async/src/App.js create mode 100644 fluent-react/examples/fallback-async/src/index.js create mode 100644 fluent-react/examples/fallback-async/src/l10n.js create mode 100644 fluent-react/src/cached_async_iterable.js 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..3acc7b899 --- /dev/null +++ b/fluent-react/src/cached_async_iterable.js @@ -0,0 +1,86 @@ +/* + * 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 = []; + } + + /** + * 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..db44dd5ae 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 = new CachedAsyncIterable(messages); this.subs = new Set(); + + this.isFetching = false; } /* @@ -39,14 +42,32 @@ 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 = new CachedAsyncIterable(messages); + // Request the first context but do not await it. + this.touchNext(1); } 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.` ); From 87be6e7d5e0c4a1a3f3556b149e4b402e5333b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Fri, 8 Jun 2018 19:54:54 +0200 Subject: [PATCH 2/2] WIP Start fixing tests --- fluent-react/src/cached_async_iterable.js | 16 ++++++++ fluent-react/src/localization.js | 15 ++++++-- fluent-react/test/localized_change_test.js | 13 +++++-- fluent-react/test/localized_render_test.js | 44 +++++----------------- 4 files changed, 46 insertions(+), 42 deletions(-) diff --git a/fluent-react/src/cached_async_iterable.js b/fluent-react/src/cached_async_iterable.js index 3acc7b899..381515eb7 100644 --- a/fluent-react/src/cached_async_iterable.js +++ b/fluent-react/src/cached_async_iterable.js @@ -23,6 +23,22 @@ export default class CachedAsyncIterable { 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. * diff --git a/fluent-react/src/localization.js b/fluent-react/src/localization.js index db44dd5ae..f99e608ee 100644 --- a/fluent-react/src/localization.js +++ b/fluent-react/src/localization.js @@ -18,7 +18,7 @@ import CachedAsyncIterable from "./cached_async_iterable"; */ export default class ReactLocalization { constructor(messages) { - this.contexts = new CachedAsyncIterable(messages); + this.contexts = CachedAsyncIterable.from(messages); this.subs = new Set(); this.isFetching = false; @@ -42,9 +42,16 @@ export default class ReactLocalization { * Set a new `messages` iterable and trigger the retranslation. */ setMessages(messages) { - this.contexts = new CachedAsyncIterable(messages); - // Request the first context but do not await it. - this.touchNext(1); + 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) { 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 }