feat: support locale-specific customFormatters#403
feat: support locale-specific customFormatters#403eemeli merged 20 commits intomessageformat:mainfrom
Conversation
packages/core/package.json
Outdated
| "Alex Sexton <alexsexton@gmail.com>", | ||
| "Eemeli Aro <eemeli@gmail.com>" | ||
| "Eemeli Aro <eemeli@gmail.com>", | ||
| "cdaringe<cdaringe@gmail.com>" |
There was a problem hiding this comment.
is that bad form 😄 ?
There was a problem hiding this comment.
I don't really mind this, but please do at least match the format of the other entries.
0ff135e to
2d2aa7c
Compare
2d2aa7c to
5e6ca52
Compare
packages/core/src/compiler.ts
Outdated
| setFormatter(key: string) { | ||
| if (this.runtimeIncludes(key, 'formatter')) return; | ||
| let cf = this.options.customFormatters[key]; | ||
| setFormatter(runtimeFormatterKey: string, originalFormatterKey?: string) { |
There was a problem hiding this comment.
e.g. setFormatter("phone__en", "phone")
There was a problem hiding this comment.
These arguments would be clearer for me with reversed order, i.e. "the key of the thing we're importing", and optionally "the alias we use for it internally". I'm also not a fan of really long names for local variables, esp. when combined with our 80 char width style.
packages/core/src/compiler.ts
Outdated
| /** | ||
| * @warn Provide the formatter implementation, but do not mutate the | ||
| * underlying function reference. | ||
| */ |
There was a problem hiding this comment.
Object.assign in the prior version was continuously writing to the formatting function. A function is an object, so of course this works. This behavior created some friction for me. In the prior implementation, the base formatter function-object was getting mutated via Object.assign on each call to setFormatter. In this new implementation, each formatting function/object gets its own protected memory. In this regard, the new locale specific formatters don't squash each others backing references within Object.assign. In other words, I've removed the mutability.
.bind() creates a new function... but doesn't bring the prototype. so, i have to splat in the prototype to make a copy. further, the runtime serializer downstream serializes the function reference to javascript with just a plain String(fn) call. if i do a .bind(), then the String(fn) turns into function() { [native code] }. That's why there is a custom toString() impl in there too.
TLDR, Object.assign was continuously crushing the formatter impl, but i needed each locale function protected/isolated, otherwise, the Object.assign would overwrite it.
This all works great, as demonstrated by the test suite :)
packages/core/src/messageformat.ts
Outdated
| formatter: LocaleModuleCustomFormatter; | ||
| arg?: 'string' | 'raw' | 'options'; | ||
| id?: string; | ||
| module?: (locale: string) => string; |
There was a problem hiding this comment.
some of these keys could be deduped, but they were small enough i just dupe'd em (e.g. arg, id)
eemeli
left a comment
There was a problem hiding this comment.
Took a quick glance, but I'll need to find time for a proper review. I'm a bit concerned about the expansion of state during the compilation; the plural arg is a bit badly named, but I would've thought that it was already tracking the current locale?
justifiably so! dig in at your convenience, let me know your thoughts
the for recollection: plurals = {
// Record<LANG_STRING, PluralData>
en: { cardinals: ..., lc: 'en', locale: 'en' }
}Logically, this makes sense. A Thus, i couldn't use the |
|
I've now also updated & fixed the docs. |
|
Hey @eemeli, i know i'm blowing up your mentions this week 😄 . I'll go silent in all other forums if it helps! Would you be willing to give me some 👀 on this anytime soon? |
|
Hi, sorry for the delay here -- been on vacation (and still am...) & trying to stay afk. Back for a couple of days next week. Reviewing this PR and how it fits in with the rest, I'm starting to get the sense that there's some premature caching happening with the messageformat/packages/core/src/compiler.ts Line 185 in 04b3952 Not sure if/when I'll have time for this myself, but for the lookup should set the locale in the plural object according to the key.
|
|
No worries. Please, ignore this, and enjoy your vacation :)
Indeed! However, locale is not in actuality available. As I mention in my comment above, |
|
This should probably be documented better. |
|
Hm. Interesting. Shucks, I feel like that there may be a modeling issue in the existing plurals interface? I would ask "is a subtag a locale?". I'd suggest it is not. Either way, it's probably worth me learning OR us collaborating on what the correct nomenclature is for
i'm ...kinda following, but not 100%. i'm not confident enough to send a patch on this yet. i think you're talking about in the If something were to happen with the above, would it be a mandatory precursor to this PR? |
Not entirely sure what you're asking for here. To clarify, The plural objects are created during the MF constructor call, in two different ways:
The disparity in expectations arises when the second path is taken, and a user uses I don't think there's really a sensible way to get around this, and I'm not sure that it should be fixed. If you're doing complicated things, you should probably not use
No, that's optimisation that should be doable entirely separately. The next step with this PR is to get rid of the locale normalisation, additional state around that, and just use |
|
😵 Got it! Thanks for setting me straight. I now understand much better and see your perspective. I've adapted the PR to your request, and it has shrunk up a bit 😄 ! |
eemeli
left a comment
There was a problem hiding this comment.
Sorry for the delayed and partial review; too many things on my to-do list atm...
Only got part of the way through the compiler; haven't looked yet at what's happening with the Object.assign/.bind() stuff or anything further than that.
packages/core/src/compile-module.ts
Outdated
| /* istanbul ignore next reason: impossible case */ | ||
| if (typeof fn.module !== 'string') { | ||
| throw new Error(`expected string module name`); | ||
| } |
There was a problem hiding this comment.
type narrowing only
packages/core/src/compiler.ts
Outdated
| const formattingModuleRequest = | ||
| formatter && 'module' in formatter ? formatter.module : null; | ||
| const isLocaleSpecificFormattingModule = | ||
| typeof formattingModuleRequest === 'function'; |
There was a problem hiding this comment.
The temporary variable is more confusing than not.
| const formattingModuleRequest = | |
| formatter && 'module' in formatter ? formatter.module : null; | |
| const isLocaleSpecificFormattingModule = | |
| typeof formattingModuleRequest === 'function'; | |
| const isLocaleSpecificFormattingModule = typeof formatter?.module === 'function'; |
Also, "isLocaleSpecificFormattingModule" is an excessively long name for a local variable. With changes below, you can probably drop it rather than renaming it.
packages/core/src/compiler.ts
Outdated
| if (!isLocaleSpecificFormattingModule) { | ||
| args.push(JSON.stringify(this.plural.locale)); | ||
| } |
There was a problem hiding this comment.
This change needs to be reverted. We still want the locale as an argument because we should not assume that each locale will have an independent implementation. It should still be possible to e.g. say that all English (en, en-US, en-CA, en-UK, ...) use the same implementation, but possibly with some region-specific considerations.
packages/core/src/compiler.ts
Outdated
| setFormatter(key: string) { | ||
| if (this.runtimeIncludes(key, 'formatter')) return; | ||
| let cf = this.options.customFormatters[key]; | ||
| setFormatter(runtimeFormatterKey: string, originalFormatterKey?: string) { |
There was a problem hiding this comment.
These arguments would be clearer for me with reversed order, i.e. "the key of the thing we're importing", and optionally "the alias we use for it internally". I'm also not a fan of really long names for local variables, esp. when combined with our 80 char width style.
Co-authored-by: Eemeli Aro <eemeli@gmail.com>
This method is used in many places, and reversing the params would be a breaking change requiring a bulk edit in this module. Give me another review pass now that it's been through a first round of requested edits and assert that you still want this. |
eemeli
left a comment
There was a problem hiding this comment.
Finally got through to the end. In general I think the approach is good, but we should be able to make the changes here more surgical; see inline comments.
packages/core/src/compiler.ts
Outdated
| const formattingModuleRequest = | ||
| formatter && 'module' in formatter ? formatter.module : null; | ||
| const isModuleFn = typeof formattingModuleRequest === 'function'; | ||
| if (!this.options.customFormatters[token.key]) { |
There was a problem hiding this comment.
| const formattingModuleRequest = | |
| formatter && 'module' in formatter ? formatter.module : null; | |
| const isModuleFn = typeof formattingModuleRequest === 'function'; | |
| if (!this.options.customFormatters[token.key]) { | |
| const isModuleFn = typeof formatter?.module === 'function'; | |
| if (!formatter) { |
There was a problem hiding this comment.
i wish we could do that, but TS doesn't allow you access an indexed field unless it's narrowed to an indexable type. string isn't indexable by string, so TS barfs. TS demands that you narrow to a form where 'module' is guaranteed on an object form indexable with formatter, so... no dice. nonetheless, in the spirit of your comment, i condensed it down to a single expression. a681a58
packages/core/src/compiler.ts
Outdated
| (...args: any[]): unknown; | ||
| id?: string | null; | ||
| module?: string | null; | ||
| module?: string | ((_: { locale: string }) => string) | null; |
There was a problem hiding this comment.
This should never be a function.
packages/core/src/compiler.ts
Outdated
|
|
||
| this.runtime[key] = Object.assign( | ||
| cf.formatter, | ||
| cloneFunction(cfo.formatter), |
There was a problem hiding this comment.
Ok, I see now why mutating the formatter can be a bad idea: When it's not a built-in formatter that we're providing, it means we're modifying a value that a user has provided.
So using .bind() here is appropriate. See below for why you ought to inline the code for that, as you had it previously.
packages/core/src/compiler.ts
Outdated
| } else if (isFormatterKey(key)) { | ||
| this.runtime[key] = Object.assign( | ||
| Formatters[key], | ||
| cloneFunction(Formatters[key]), |
There was a problem hiding this comment.
OTOH, here the formatter is an internal one, so we don't need to clone it and can mutate it without ill effect.
There was a problem hiding this comment.
it's your call. i am biased to not mutating the underlying fn ever, out of what I perceive to be general purpose best practices, but that is entirely subjective :)
There was a problem hiding this comment.
Let's just mutate it, and inline the cloneFunction() above.
packages/core/src/compiler.ts
Outdated
| ? { | ||
| id: identifier(cf.id), | ||
| module: | ||
| typeof cf.module === 'function' && this.plural.locale |
There was a problem hiding this comment.
Checking this.plural.locale is incorrect here. If it may be null, then either it needs to be coerced to an empty string, or the function needs to be able to deal with a null input.
Fixing this will let you drop the compile-module.ts change.
packages/core/src/messageformat.ts
Outdated
| export type CustomFormatter = | ||
| | DefaultCustomFormatter | ||
| | LocaleModuleCustomFormatter; |
There was a problem hiding this comment.
As we're now always including the locale code, am I right that the changes to this signature should be rolled back?
There was a problem hiding this comment.
No, otherwise I’m in no better position than when I started without the PR. the function form is precisely what lets me generate locale based module imports. The param is runtime, versus compile time
There was a problem hiding this comment.
The function form of module allows that. This is the signature of the function that'll be called during the formatting. After #403 (comment), it'll always include the locale argument.
There was a problem hiding this comment.
got it. i misinterpreted. my bad. addressed in a681a58
packages/core/src/messageformat.ts
Outdated
| formatter: LocaleModuleCustomFormatter; | ||
| arg?: 'string' | 'raw' | 'options'; | ||
| id?: string; | ||
| module?: (_: { locale: string }) => string; |
There was a problem hiding this comment.
Why does this expect the locale to be wrapped within an object? Wouldn't it be simpler to use (locale: string) => string?
There was a problem hiding this comment.
I had it that way originally, but i changed it to the object form so it could be extended without being a breaking change
There was a problem hiding this comment.
Locale is good to keep as a separate argument. For example, that's the way it is in the MessageFormat constructor. Adding more arguments can be done later.
There was a problem hiding this comment.
sure, i can get on board with that. refactored in ee31644
Let's return to this after the current round of changes. My main concern is that with separate terms for the local variable name and the import name, those terms ought to correspond to the names used for the JS |
|
Alrighty, pass #3, complete! Back to you! |
packages/core/src/compiler.ts
Outdated
| formatter && | ||
| 'module' in formatter && | ||
| typeof formatter.module === 'function'; | ||
| if (!this.options.customFormatters[token.key]) { |
There was a problem hiding this comment.
| if (!this.options.customFormatters[token.key]) { | |
| if (!formatter) { |
eemeli
left a comment
There was a problem hiding this comment.
Nearly there, I think. See inline replies.
packages/core/package.json
Outdated
| "Alex Sexton <alexsexton@gmail.com>", | ||
| "Eemeli Aro <eemeli@gmail.com>" | ||
| "Eemeli Aro <eemeli@gmail.com>", | ||
| "cdaringe<cdaringe@gmail.com>" |
There was a problem hiding this comment.
I don't really mind this, but please do at least match the format of the other entries.
packages/core/src/compiler.ts
Outdated
| } else if (isFormatterKey(key)) { | ||
| this.runtime[key] = Object.assign( | ||
| Formatters[key], | ||
| cloneFunction(Formatters[key]), |
There was a problem hiding this comment.
Let's just mutate it, and inline the cloneFunction() above.
|
pass 4 complete. back to you! thanks for the feedback. i think the PR is in much better shape that it was on first pass |
eemeli
left a comment
There was a problem hiding this comment.
There are still a few code style issues in setFormatter(), but we can take care of them separately later.
The docs at docs/custom-formatters.md will need to get updated together with the next release. @cdaringe, would you be able/interested to submit a separate PR for that?
|
sound good. please see #408 |
Problem Statement
Given a locale & a message with a custom formatter requirement, I want my compiled output to use a formatter tailored to that locale, but cannot due to limitations in the current API.
Closes #402
Motivating Context
Presume that some custom formatter is expensive to load for all supported locales.
In the current API, my formatter must do something like this:
Presume that each formatting function is expensive, say 1kb. Now my app must install (N-1) kb of wasted formatters on each application load.
Solution
Support locale-aware custom formatters.
In the new state, customFormatters do not need to bundle all locale concerns into a single module.