diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 7387c5b4..0d9783f9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,6 +1,6 @@ -# Frontend dependency updates +# Frontend gotchas -Non-obvious things that bit us during the pnpm 8→10 / node 16→20 migration (PR #2091, fixing #2085). Read before bumping dependencies or the node/pnpm toolchain again. +Non-obvious constraints in the frontend toolchain and widget. Read before bumping dependencies or the node/pnpm versions, and before changing how the widget renders or how translations are extracted. ## The node/pnpm version is pinned in many places, not one @@ -22,10 +22,8 @@ When bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `e ## pnpm 10's stricter `node-linker` layout needs explicit pins -A few deps needed pinning specifically because of pnpm 10's hoisting changes, not because of the deps themselves: -- `react-intl` 6.0.5 — newer versions' type declarations break the preact-compat alias setup +One dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself: - `@types/minimatch` 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types -- `cheerio` 1.0.0-rc.12 — 1.2 is ESM-only and breaks under jest 28 If a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency. @@ -59,8 +57,7 @@ without `import { h }` would type-check and lint clean, then throw at runtime, b These were deliberately not bumped because each is a config-migration or bundle-changing major, not a drop-in update — don't bump them opportunistically inside an unrelated dependency PR: - `eslint` 8 (9/10 need flat-config migration), `stylelint` 14 (16 has breaking rule changes), `babel` 7, `jest` 28 (30 needs config changes) -- `react`/`react-dom` (the app uses `preact` aliased as `react`/`react-dom` via `preact/compat` — don't "fix" this by installing real react) -- `redux`/`react-redux`, `tailwindcss` 3.4 (v4 is a full config rewrite), `@11ty/eleventy` 2 (v3 is an ESM migration) in `site/` +- `redux` 4, `tailwindcss` 3.4 (v4 is a full config rewrite), `@11ty/eleventy` 2 (v3 is an ESM migration) in `site/` ## `html-minifier` is abandoned — use `html-minifier-terser` @@ -75,3 +72,52 @@ There's no automated build-output diff in CI. Before merging a dependency PR tha ## Where the alerts actually were When clearing Dependabot/audit alerts, check whether the flagged package is actually reachable from production code or only from the dev/test toolchain — `pnpm audit`/`yarn audit` don't distinguish. Several alerts here were in build-time-only tooling (webpack-dev-server, laravel-mix-equivalent dev deps) with no patched release available; those are lower-risk than a runtime dependency with the same severity label. + +## Don't import `preact/compat` + +It installs hooks on preact's shared `options` that remap `onFocus`/`onBlur` to +`focusin`/`focusout` on every element and make `@testing-library/preact` rewrite `change` to +`input`, so one import changes event behaviour across the whole widget, and it adds about +3.8 kB gzipped. + +`forwardRef` lives only there, so a component that needs a ref takes it as an ordinary prop; +`TextareaAutosize` is the pattern. For a component type annotation use `FunctionComponent` +from `preact`, not `React.FC`. + +## i18n is a hand-written binding whose export names are fixed by the extractor + +`app/common/intl.tsx` provides `IntlProvider`, `useIntl`, `createIntl`, `defineMessages`, +`FormattedMessage` and `IntlShape`. `formatjs extract` (`translation:extract`) finds messages +by recognising the identifiers `defineMessages`, `FormattedMessage` and `intl.formatMessage` +in the AST, not by import source, so those three names are fixed. Rename any of them and +extraction returns nothing, with no error and a zero exit code. + +The damage lands on the next step. `translation:check` compares locale keys against extracted +keys and fails loudly, but `tasks/generateDictionary.js` calls `removeAbandonedKeys`, so +`translation:generate` after a rename deletes the now-unextracted keys from all 24 files in +`app/locales/` and writes them back, after which the check passes. Extract-then-generate is +the documented translator workflow, so the destructive order is the normal one. + +After any rename or refactor touching those identifiers, verify the count rather than the exit +code: + +```sh +pnpm translation:extract +node -e "console.log(Object.keys(require('./extracted-messages/messages')).length)" +``` + +It must match the key count in `app/locales/en.json`, not drop to 0. + +The binding implements `{name}` interpolation and paired `text` rich text, and +nothing else of ICU: no plural, select or selectordinal, no typed arguments such as +`{n, number}`, and no apostrophe quoting. A plural or select form has braces the placeholder +rule rejects, so it falls back to English wherever values are passed and shows its raw text +where they are not; `''` simply stays as two apostrophes. + +`translation:check` validates every translated value's markup and placeholders against the +English string it translates, mirroring the binding's rule, and the catalogue sweep in +`app/common/intl.test.tsx` additionally renders the two messages that carry a link. What +neither catches is unsupported ICU syntax, since a plural form is well-formed text as far as +both are concerned. A message the binding cannot resolve falls back to the English source +rather than reaching the page, so without these checks a broken translation is invisible in +the interface. diff --git a/frontend/apps/remark42/.size-limit.js b/frontend/apps/remark42/.size-limit.js index 2036a041..5507ca60 100644 --- a/frontend/apps/remark42/.size-limit.js +++ b/frontend/apps/remark42/.size-limit.js @@ -5,7 +5,7 @@ module.exports = [ }, { path: 'public/remark.mjs', - limit: '80 KB', + limit: '60 KB', }, { path: 'public/remark.css', @@ -13,7 +13,7 @@ module.exports = [ }, { path: 'public/last-comments.mjs', - limit: '40 KB', + limit: '20 KB', }, { path: 'public/last-comments.css', @@ -21,7 +21,7 @@ module.exports = [ }, { path: 'public/deleteme.mjs', - limit: '15 KB', + limit: '9.5 KB', }, { path: 'public/counter.mjs', diff --git a/frontend/apps/remark42/app/common/intl.test.tsx b/frontend/apps/remark42/app/common/intl.test.tsx new file mode 100644 index 00000000..c7a45803 --- /dev/null +++ b/frontend/apps/remark42/app/common/intl.test.tsx @@ -0,0 +1,329 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + +import { render, screen } from '@testing-library/preact'; + +import { FormattedMessage, IntlProvider, createIntl, defineMessages, useIntl } from './intl'; + +const descriptor = { id: 'greeting', defaultMessage: 'Hello {name}' }; + +describe('createIntl', () => { + it('prefers the catalogue over the default message', () => { + const intl = createIntl('ru', { greeting: 'Привет {name}' }); + + expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Привет Sam'); + }); + + it('falls back to the default message when the key is absent', () => { + const intl = createIntl('en', {}); + + expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Hello Sam'); + }); + + it('leaves a placeholder without a value untouched', () => { + const intl = createIntl('en', {}); + + expect(intl.formatMessage(descriptor)).toBe('Hello {name}'); + }); + + it('accepts whitespace inside the braces, as ICU does', () => { + const intl = createIntl('en', { greeting: 'Hello { name }' }); + + expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Hello Sam'); + }); + + it('treats an empty catalogue entry as missing', () => { + const intl = createIntl('ru', { greeting: '' }); + + expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Hello Sam'); + }); + + it('degrades to the raw value when a date cannot be formatted', () => { + const intl = createIntl('en', {}); + const invalid = new Date('nonsense'); + + expect(intl.formatDate(invalid)).toBe(String(invalid)); + expect(intl.formatTime(invalid)).toBe(String(invalid)); + }); + + it('formats time with hour and minute by default', () => { + const intl = createIntl('en', {}); + const at = new Date(2026, 0, 2, 15, 4); + + expect(intl.formatTime(at)).toBe(new Intl.DateTimeFormat('en', { hour: 'numeric', minute: 'numeric' }).format(at)); + expect(intl.formatDate(at)).toBe(new Intl.DateTimeFormat('en').format(at)); + }); + + // formatters are cached, so the cache key has to separate locales and option sets + it('does not confuse cached formatters across locales and options', () => { + const at = new Date(2026, 0, 2, 15, 4); + + expect(createIntl('ru', {}).formatDate(at)).not.toBe(createIntl('en', {}).formatDate(at)); + expect(createIntl('en', {}).formatDate(at, { dateStyle: 'full' })).not.toBe(createIntl('en', {}).formatDate(at)); + expect(createIntl('en', {}).formatDate(at)).toBe(createIntl('en', {}).formatDate(at)); + }); + + it('honours explicit time options instead of adding defaults', () => { + const intl = createIntl('en', {}); + const at = new Date(2026, 0, 2, 15, 4); + + expect(intl.formatTime(at, { hour: '2-digit' })).toBe( + new Intl.DateTimeFormat('en', { hour: '2-digit' }).format(at) + ); + }); +}); + +describe('defineMessages', () => { + it('returns its argument unchanged', () => { + const messages = defineMessages({ greeting: descriptor }); + + expect(messages.greeting).toBe(descriptor); + }); +}); + +describe('', () => { + const link = { id: 'powered-by', defaultMessage: 'Powered by Remark42' }; + + function renderWith(locale: string, messages: Record) { + return render( + + {chunk} }} /> + + ); + } + + it('wraps the tagged chunk with the handler', () => { + const { container } = renderWith('en', {}); + + expect(container.textContent).toBe('Powered by Remark42'); + expect(screen.getByRole('link').textContent).toBe('Remark42'); + }); + + it('uses the translated text around the tag', () => { + const { container } = renderWith('ru', { 'powered-by': 'Работает на базе Remark42' }); + + expect(container.textContent).toBe('Работает на базе Remark42'); + }); + + // both shapes fall back to the source rather than reaching the page + it('falls back to the default message when a tag carries attributes', () => { + const { container } = renderWith('mk', { + 'powered-by': "Овозможено од Диосфера", + }); + + expect(container.textContent).toBe('Powered by Remark42'); + }); + + // ro drops the link from both rich-text strings, which is valid + it('renders a translation that does not use the tag at all', () => { + const { container } = renderWith('ro', { 'powered-by': 'Cu sprijinul Remark42' }); + + expect(container.textContent).toBe('Cu sprijinul Remark42'); + expect(container.querySelector('a')).toBeNull(); + }); + + it('handles the same tag appearing more than once', () => { + const { container } = render( + one and two' }}> + {chunk} }} /> + + ); + + expect(container.textContent).toBe('one and two'); + expect(container.querySelectorAll('a')).toHaveLength(2); + }); + + // one well-formed pair must not vouch for a stray reference elsewhere + it('falls back when a stray tag sits beside a well-formed pair', () => { + const { container } = renderWith('xx', { 'powered-by': 'A B C ' }); + + expect(container.textContent).toBe('Powered by Remark42'); + }); + + // a translator inventing gets English rather than visible markup + it('falls back when the translation introduces a tag nothing handles', () => { + const { container } = renderWith('xx', { 'powered-by': 'Made with love' }); + + expect(container.textContent).toBe('Powered by Remark42'); + }); + + it('falls back to the default message when a tag is unclosed', () => { + const { container } = renderWith('th', { 'powered-by': 'ระบบแสดงความคิดเห็นโดย Remark42 { + it('throws outside of a provider', () => { + function Orphan() { + useIntl(); + + return null; + } + + expect(() => render()).toThrow('intl accessed outside of an IntlProvider'); + }); +}); + +describe('formatting details that no shipped message exercises yet', () => { + it('interpolates inside a rich-text chunk', () => { + const intl = createIntl('en', { hi: 'Hello {name}' }); + + expect(intl.formatMessage({ id: 'hi', defaultMessage: 'x' }, { name: 'Sam', b: (c: string) => c })).toEqual([ + 'Hello ', + 'Sam', + ]); + }); + + it('falls back to the id when there is no translation and no default', () => { + expect(createIntl('en', {}).formatMessage({ id: 'orphan.key' })).toBe('orphan.key'); + }); + + // a pair nothing handles is a format error + it('falls back when the translation adds a pair nothing handles', () => { + const intl = createIntl('en', { k: 'See HTML and link' }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go home' }, { a: (c: string) => c })).toEqual([ + 'Go ', + 'home', + ]); + }); + + // any tag that cannot resolve makes the whole string fall back, rather than + // putting raw markup on the page + it.each([ + ['an unclosed tag of another name', 'Go home home'], + ['a tag carrying an attribute', "Go home"], + ['spaces inside the brackets', 'Go < a >home'], + ['a tag nested inside a handled one', 'Go home'], + ])('falls back on %s', (_name, message) => { + const intl = createIntl('en', { k: message }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go home' }, { a: (c: string) => c })).toEqual([ + 'Go ', + 'home', + ]); + }); + + it.each([ + ['an unclosed brace', 'Hello {name and R'], + ['doubled braces', 'Hello {{name}} R'], + ['a stray closing brace', 'Hello name} R'], + ])('falls back on %s', (_name, message) => { + const intl = createIntl('en', { k: message }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go home' }, { a: (c: string) => c })).toEqual([ + 'Go ', + 'home', + ]); + }); + + // formatjs leaves a self-closing tag as text, so it must not trigger the fallback + it('renders a self-closing tag as text', () => { + const intl = createIntl('en', { k: 'Go home
' }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'default' }, { a: (c: string) => c })).toEqual([ + 'Go ', + 'home', + '
', + ]); + }); + + it('leaves a bare less-than sign alone', () => { + const intl = createIntl('en', { k: 'Under < 10 via here' }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'default' }, { a: (c: string) => c })).toEqual([ + 'Under < 10 via ', + 'here', + ]); + }); + + it('treats a non-ascii suffix as a reference to the tag, and falls back', () => { + const intl = createIntl('en', { k: 'X' }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go home' }, { a: (c: string) => c })).toEqual([ + 'Go ', + 'home', + ]); + }); +}); + +describe('every shipped catalogue', () => { + const localesDir = join(__dirname, '..', 'locales'); + const catalogues = readdirSync(localesDir) + .filter((file) => file.endsWith('.json')) + .map( + (file) => + [file.replace('.json', ''), JSON.parse(readFileSync(join(localesDir, file), 'utf8'))] as [ + string, + Record + ] + ); + + const richText = [ + { id: 'root.powered-by', defaultMessage: 'Powered by Remark42' }, + { id: 'commentForm.notice-about-styling', defaultMessage: 'Styling with Markdown is supported' }, + ]; + + // only the handled tag is stripped; a self-closing tag stays, as the binding keeps it + const withoutTags = (message: string) => message.replace(/<\/?a>/g, ''); + + // reads the real files, so a malformed entry added to any locale later fails here: the + // binding falls back to English on one, and the assertion below expects the locale's own + // words. structural validation of every other value is checkTranslation.js's job + it.each(catalogues)('%s renders its own words in its rich-text messages', (locale, messages) => { + const intl = createIntl(locale, messages); + + richText.forEach((descriptor) => { + const rendered = ([] as unknown[]) + .concat(intl.formatMessage(descriptor, { a: (chunk: string) => chunk }) as never) + .join(''); + // an empty entry counts as missing, matching the binding + const translated = messages[descriptor.id] || undefined; + + expect(rendered).toBe(withoutTags(translated ?? descriptor.defaultMessage)); + }); + }); + + it('formats dates and times in the widget locale rather than always in English', () => { + const at = new Date(2026, 0, 2, 15, 4); + + expect(createIntl('ru', {}).formatDate(at)).not.toBe(createIntl('en', {}).formatDate(at)); + expect(createIntl('ru', {}).formatTime(at)).not.toBe(createIntl('en', {}).formatTime(at)); + }); +}); + +describe('branches with no shipped input yet', () => { + it('renders the broken message when there is nothing to fall back to', () => { + const intl = createIntl('xx', { k: 'Broken link c })).toEqual(['Broken link { + const intl = createIntl('en', { k: '{a} and link' }); + + expect(intl.formatMessage({ id: 'k', defaultMessage: 'x' }, { a: (c: string) => c })).toEqual(['{a} and ', 'link']); + }); + + it('escapes a tag name that carries a regular-expression metacharacter', () => { + const intl = createIntl('en', { k: 'a.b x c' }); + + expect(() => intl.formatMessage({ id: 'k', defaultMessage: 'y' }, { 'a.b': (c: string) => c })).not.toThrow(); + }); + + it.each([['minute'], ['second'], ['dateStyle'], ['timeStyle']])( + 'does not add hour and minute when %s is given', + (option) => { + const intl = createIntl('en', {}); + const at = new Date(2026, 0, 2, 15, 4); + const options = { [option]: option === 'dateStyle' || option === 'timeStyle' ? 'short' : 'numeric' }; + + expect(intl.formatTime(at, options as Intl.DateTimeFormatOptions)).toBe( + new Intl.DateTimeFormat('en', options as Intl.DateTimeFormatOptions).format(at) + ); + } + ); +}); diff --git a/frontend/apps/remark42/app/common/intl.tsx b/frontend/apps/remark42/app/common/intl.tsx new file mode 100644 index 00000000..c1dd796d --- /dev/null +++ b/frontend/apps/remark42/app/common/intl.tsx @@ -0,0 +1,253 @@ +import { createContext, Fragment, type ComponentChildren } from 'preact'; +import { useContext } from 'preact/hooks'; + +/** + * Minimal i18n binding over Preact context. + * + * Only what the widget uses: a provider, a hook, message formatting with `{name}` + * interpolation and `chunk` rich text, and date/time formatting through + * `Intl.DateTimeFormat`. + * + * `formatjs extract` finds messages by recognising the identifiers `defineMessages`, + * `FormattedMessage` and `intl.formatMessage` in the AST rather than by import source, + * so those three names are fixed. Renaming one silently empties the extracted catalogue. + * + * A message the binding cannot parse falls back to the message in the source: a broken, + * unhandled or nested tag, a brace that is not a well-formed placeholder, and a + * placeholder naming a value the caller did not supply. + * + * What this deliberately does not implement, none of which any of the 24 catalogues + * uses: ICU plural, select and selectordinal; typed arguments such as `{n, number}` + * or `{d, date}`; and ICU apostrophe quoting. A plural or select form carries braces the + * placeholder rule rejects, so it falls back wherever values are passed and renders raw + * where they are not. Quoting is not interpreted at all: `''` stays two apostrophes and + * `'{name}'` interpolates with the quotes in place. Add support before accepting one. + */ + +type MessageDescriptor = { + id: string; + defaultMessage?: string; + description?: string; +}; + +/** A rich-text handler, wrapping the text between a matching pair of tags. */ +type ChunkFormatter = (chunk: string) => ComponentChildren; + +type PrimitiveValues = Record; +type MessageValues = Record; + +export type IntlShape = { + locale: string; + formatMessage(descriptor: MessageDescriptor, values?: PrimitiveValues): string; + formatMessage(descriptor: MessageDescriptor, values: MessageValues): ComponentChildren; + formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string; + formatTime(value: Date | number, options?: Intl.DateTimeFormatOptions): string; +}; + +/** + * Identity function. Its first type parameter is the key type rather than the record, + * so callers can constrain keys explicitly, as in `defineMessages`. + */ +export function defineMessages = Record>( + messages: U +): U { + return messages; +} + +const PLACEHOLDER = /\{\s*(\w+)\s*\}/g; + +/** Replaces `{name}`, tolerating whitespace inside the braces, and leaves a placeholder without a value untouched. */ +function interpolate(message: string, values: MessageValues): string { + return message.replace(PLACEHOLDER, (match, key: string) => { + const value = values[key]; + + return value === undefined || typeof value === 'function' ? match : String(value); + }); +} + +/** Escapes a tag name so a metacharacter in it cannot change or break the pattern. */ +function escapeForRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Matches a well-formed pair for any of the given tag names, capturing the name and the chunk. */ +function pairMatcher(tags: string[], flags = ''): RegExp { + return new RegExp(`<(${tags.map(escapeForRegExp).join('|')})>([\\s\\S]*?)`, flags); +} + +/** A self-closing tag is text, not markup, matching formatjs. */ +const SELF_CLOSING = /<[a-zA-Z][\w-]*\s*\/>/g; + +/** Any `` + const withoutPairs = + tags.length === 0 + ? message + : message.replace(pairMatcher(tags, 'g'), (_match, _name: string, chunk: string) => { + chunks.push(chunk); + + return ''; + }); + const residue = withoutPairs.replace(SELF_CLOSING, ''); + + return TAG_START.test(residue) || chunks.some((chunk) => TAG_START.test(chunk.replace(SELF_CLOSING, ''))); +} + +/** True when the message cannot be parsed and the source message should be used instead. */ +function isBroken(message: string, values: MessageValues, tags: string[]): boolean { + return hasBrokenMarkup(message, tags) || hasBrokenPlaceholders(message, values); +} + +/** Splits a message into text and the results of its rich-text handlers. */ +function formatRich(message: string, values: MessageValues, tags: string[]): ComponentChildren[] { + const parts: ComponentChildren[] = []; + let cursor = 0; + + for (const match of message.matchAll(pairMatcher(tags, 'g'))) { + const start = match.index as number; + + if (start > cursor) { + parts.push(interpolate(message.slice(cursor, start), values)); + } + + parts.push((values[match[1]] as ChunkFormatter)(interpolate(match[2], values))); + cursor = start + match[0].length; + } + + if (cursor < message.length) { + parts.push(interpolate(message.slice(cursor), values)); + } + + return parts; +} + +function format(message: string, fallback: string | undefined, values?: MessageValues): ComponentChildren { + if (!values) { + return message; + } + + const tags = Object.keys(values).filter((key) => typeof values[key] === 'function'); + // a message that cannot be parsed falls back to the source rather than reaching the + // page. this has to run whether or not the message carries rich text, since a mistyped + // placeholder is the likelier mistake + const source = isBroken(message, values, tags) && fallback !== undefined ? fallback : message; + + return tags.length === 0 ? interpolate(source, values) : formatRich(source, values, tags); +} + +/** + * `Intl.DateTimeFormat` is expensive to construct and every comment formats a date and a + * time, so instances are reused. + */ +const formatters = new Map(); + +function dateTimeFormat(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = `${locale}\u0000${options ? JSON.stringify(options) : ''}`; + let formatter = formatters.get(key); + + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale, options); + formatters.set(key, formatter); + } + + return formatter; +} + +/** Formats a date, degrading to the raw value rather than throwing mid-render. */ +function formatWith(locale: string, value: Date | number, options?: Intl.DateTimeFormatOptions): string { + try { + return dateTimeFormat(locale, options).format(value); + } catch { + return String(value); + } +} + +export function createIntl(locale: string, messages: Record): IntlShape { + function formatMessage(descriptor: MessageDescriptor, values?: MessageValues) { + // the catalogue wins, then the message in the source. english ships an empty + // catalogue, so defaultMessage is the primary path rather than a fallback. + // an empty entry counts as missing + const message = messages[descriptor.id] || descriptor.defaultMessage || descriptor.id; + + return format(message, descriptor.defaultMessage, values); + } + + return { + formatMessage: formatMessage as IntlShape['formatMessage'], + locale, + formatDate(value, options) { + return formatWith(locale, value, options); + }, + formatTime(value, options) { + // hour and minute are added unless the caller asked for a time component itself; + // every call here passes no options at all + const needsDefaults = + !options?.hour && !options?.minute && !options?.second && !options?.dateStyle && !options?.timeStyle; + const resolved: Intl.DateTimeFormatOptions = needsDefaults + ? { ...options, hour: 'numeric', minute: 'numeric' } + : options; + + return formatWith(locale, value, resolved); + }, + }; +} + +const IntlContext = createContext(null); + +export function IntlProvider({ + locale, + messages, + children, +}: { + locale: string; + messages: Record; + children?: ComponentChildren; +}) { + return {children}; +} + +export function useIntl(): IntlShape { + const intl = useContext(IntlContext); + + if (!intl) { + throw new Error('intl accessed outside of an IntlProvider'); + } + + return intl; +} + +export function FormattedMessage({ values, ...descriptor }: MessageDescriptor & { values?: MessageValues }) { + const intl = useIntl(); + + return {intl.formatMessage(descriptor, values ?? {})}; +} diff --git a/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx b/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx index fc3aad89..4b4465a5 100644 --- a/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx @@ -1,9 +1,9 @@ -import { mount } from 'enzyme'; +import { render } from '@testing-library/preact'; import createMockStore from 'redux-mock-store'; import { Middleware } from 'redux'; import { Provider } from 'store/context'; -import { IntlProvider } from 'react-intl'; +import { IntlProvider } from 'common/intl'; import type { User } from 'common/types'; import enMessages from 'locales/en.json'; @@ -32,7 +32,7 @@ const mockStore = createMockStore([] as Middleware[]); describe('', () => { const createWrapper = (props: Props = DefaultProps, store: ReturnType = mockStore(initialStore)) => - mount( + render( @@ -42,57 +42,53 @@ describe('', () => { describe('For not authorized : null', () => { it('should not render settings if there is no hidden users', () => { - const element = createWrapper({ + const { container } = createWrapper({ ...DefaultProps, user: null, postInfo: { ...DefaultProps.postInfo, read_only: true }, } as Props); - const adminAction = element.find(`.${styles.adminAction}`); - - expect(adminAction.exists()).toBe(false); + expect(container.querySelector(`.${styles.adminAction}`)).toBeNull(); }); it('should render settings if there is some hidden users', () => { - const element = createWrapper({ + const { container } = createWrapper({ ...DefaultProps, user: null, postInfo: { ...DefaultProps.postInfo, read_only: true }, hiddenUsers: { hidden_joe: {} as User }, } as Props); - const adminAction = element.find(`.${styles.adminAction}`).first(); + const adminAction = container.querySelector(`.${styles.adminAction}`); - expect(adminAction.text()).toEqual('Show settings'); + expect(adminAction?.textContent).toEqual('Show settings'); }); }); describe('For authorized user', () => { it('should render info about current user', () => { - const element = createWrapper({ + const { container } = createWrapper({ ...DefaultProps, user: { id: 'john', name: 'John' }, } as Props); - const authPanelColumn = element.find(`.${styles.column}`); + const authPanelColumn = container.querySelectorAll(`.${styles.column}`); - expect(authPanelColumn.length).toEqual(2); - - const userInfo = authPanelColumn.first(); - - expect(userInfo.text()).toEqual(expect.stringContaining('John')); + expect(authPanelColumn).toHaveLength(2); + expect(authPanelColumn[0].textContent).toEqual(expect.stringContaining('John')); }); }); + describe('For admin user', () => { it('should render admin action', () => { - const element = createWrapper({ + const { container } = createWrapper({ ...DefaultProps, user: { id: 'test', admin: true, name: 'John' }, } as Props); - const adminAction = element.find(`.${styles.adminAction}`).first(); + const adminAction = container.querySelector(`.${styles.adminAction}`); - expect(adminAction.text()).toEqual('Show settings'); + expect(adminAction?.textContent).toEqual('Show settings'); }); }); }); diff --git a/frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx b/frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx index 436023b0..b0fef43a 100644 --- a/frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx +++ b/frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx @@ -1,7 +1,7 @@ import { h, Component } from 'preact'; -import { FormattedMessage, IntlShape, useIntl } from 'react-intl'; import clsx from 'clsx'; +import { FormattedMessage, IntlShape, useIntl } from 'common/intl'; import { User, Theme, PostInfo } from 'common/types'; import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants'; import { postMessageToParent } from 'utils/post-message'; diff --git a/frontend/apps/remark42/app/components/auth/auth.hooks.ts b/frontend/apps/remark42/app/components/auth/auth.hooks.ts index 64271ea1..8b157775 100644 --- a/frontend/apps/remark42/app/components/auth/auth.hooks.ts +++ b/frontend/apps/remark42/app/components/auth/auth.hooks.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useMemo } from 'preact/hooks'; -import { useIntl } from 'react-intl'; +import { useIntl } from 'common/intl'; import { errorMessages, RequestError } from 'utils/errorUtils'; import { isObject } from 'utils/is-object'; import { parseMessage, updateIframeHeight } from 'utils/post-message'; diff --git a/frontend/apps/remark42/app/components/auth/auth.messages.ts b/frontend/apps/remark42/app/components/auth/auth.messages.ts index da69af70..b469ac76 100644 --- a/frontend/apps/remark42/app/components/auth/auth.messages.ts +++ b/frontend/apps/remark42/app/components/auth/auth.messages.ts @@ -1,4 +1,4 @@ -import { defineMessages } from 'react-intl'; +import { defineMessages } from 'common/intl'; export const messages = defineMessages({ signin: { diff --git a/frontend/apps/remark42/app/components/auth/auth.tsx b/frontend/apps/remark42/app/components/auth/auth.tsx index bd98e6ac..1026538e 100644 --- a/frontend/apps/remark42/app/components/auth/auth.tsx +++ b/frontend/apps/remark42/app/components/auth/auth.tsx @@ -1,9 +1,9 @@ import clsx from 'clsx'; import { h, Fragment, JSX } from 'preact'; import { useState, useRef } from 'preact/hooks'; -import { useIntl } from 'react-intl'; import { useDispatch } from 'store/context'; +import { useIntl } from 'common/intl'; import { setUser } from 'store/user/actions'; import { Input } from 'components/input'; import { TelegramLink } from 'components/telegram/telegram-link'; diff --git a/frontend/apps/remark42/app/components/auth/components/oauth.tsx b/frontend/apps/remark42/app/components/auth/components/oauth.tsx index 7c4d7b61..fedfdb19 100644 --- a/frontend/apps/remark42/app/components/auth/components/oauth.tsx +++ b/frontend/apps/remark42/app/components/auth/components/oauth.tsx @@ -1,7 +1,7 @@ import { h, JSX } from 'preact'; import clsx from 'clsx'; -import { useIntl } from 'react-intl'; +import { useIntl } from 'common/intl'; import type { OAuthProvider } from 'common/types'; import { siteId } from 'common/settings'; import { useTheme } from 'hooks/useTheme'; diff --git a/frontend/apps/remark42/app/components/button/button.tsx b/frontend/apps/remark42/app/components/button/button.tsx index d675f370..ceb3ebd1 100644 --- a/frontend/apps/remark42/app/components/button/button.tsx +++ b/frontend/apps/remark42/app/components/button/button.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; import { h, type ButtonHTMLAttributes } from 'preact'; -import { forwardRef } from 'preact/compat'; import type { Theme } from 'common/types'; @@ -25,8 +24,8 @@ export type ButtonProps = Omit, 'size' | className?: string; }; -export const Button = forwardRef( - ({ children, theme, mix, kind, type = 'button', size, className, ...props }, ref) => ( +export function Button({ children, theme, mix, kind, type = 'button', size, className, ...props }: ButtonProps) { + return ( - ) -); + ); +} diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx index 54124001..4ca3ddf4 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx @@ -1,9 +1,8 @@ -import { mount } from 'enzyme'; +import '@testing-library/jest-dom'; +import { fireEvent, screen } from '@testing-library/preact'; import { act } from 'preact/test-utils'; -import { Provider } from 'store/context'; -import { Middleware } from 'redux'; -import createMockStore from 'redux-mock-store'; -import { IntlProvider } from 'react-intl'; + +import { render } from 'tests/utils'; jest.mock('common/api'); @@ -11,11 +10,9 @@ import { user, anonymousUser } from '__stubs__/user'; import { validToken } from '__stubs__/jwt'; import { emailVerificationForSubscribe, emailConfirmationForSubscribe, unsubscribeFromEmailUpdates } from 'common/api'; import { sleep } from 'utils/sleep'; -import { Input } from 'components/input'; -import { Button } from 'components/button'; -import { Dropdown } from 'components/dropdown'; import { persistEmail } from 'components/auth/auth.utils'; -import enMessages from 'locales/en.json'; + +import { StoreState } from 'store'; import { SubscribeByEmail, SubscribeByEmailForm } from '.'; import { RequestError } from '../../../utils/errorUtils'; @@ -38,126 +35,105 @@ const initialStore = { theme: 'light', } as const; -const mockStore = createMockStore([] as Middleware[]); - -const makeInputEvent = (value: string) => ({ - preventDefault: jest.fn(), - target: { - value, - }, -}); - jest.mock('utils/jwt', () => ({ isJwtExpired: jest.fn(() => false), })); +// call history accumulates across the file otherwise, so a toHaveBeenCalledWith can be +// satisfied by an earlier test. only the history is cleared, since the module-scope +// mockImplementation above has to survive into every test +beforeEach(() => { + emailVerificationForSubscribeMock.mockClear(); + emailConfirmationForSubscribeMock.mockClear(); + unsubscribeFromEmailUpdatesMock.mockClear(); +}); + describe('', () => { - const createWrapper = (store: ReturnType = mockStore(initialStore)) => - mount( - - - - - - ); + const createWrapper = (state: Partial = initialStore) => render(, state); it('should be rendered with disabled email button when user is anonymous', () => { - const store = mockStore({ ...initialStore, user: anonymousUser }); - const wrapper = createWrapper(store); - const dropdown = wrapper.find(Dropdown); + const { container } = createWrapper({ ...initialStore, user: anonymousUser }); + const button = screen.getByRole('button'); - expect(dropdown.prop('disabled')).toEqual(true); - expect(dropdown.prop('buttonTitle')).toEqual('Available only for registered users'); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(button).toBeDisabled(); + expect(button.getAttribute('title')).toEqual('Available only for registered users'); }); it('should be rendered with enabled email button when user is registrated', () => { - const store = mockStore(initialStore); - const wrapper = createWrapper(store); - const dropdown = wrapper.find(Dropdown); + const { container } = createWrapper(initialStore); + const button = screen.getByRole('button'); - expect(dropdown.prop('disabled')).toEqual(false); - expect(dropdown.prop('buttonTitle')).toEqual('Subscribe by Email'); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(button).not.toBeDisabled(); + expect(button.getAttribute('title')).toEqual('Subscribe by Email'); }); }); describe('', () => { - const createWrapper = (store: ReturnType = mockStore(initialStore)) => - mount( - - - - - - ); - it('should render email form by default', () => { - const store = mockStore(initialStore); - const wrapper = createWrapper(store); - const title = wrapper.find(`.${styles.title}`); - const button = wrapper.find(Button); + const createWrapper = (state: Partial = initialStore) => render(, state); - expect(title.text()).toEqual('Subscribe to replies'); - expect(button.prop('children')).toEqual('Submit'); - expect(button.prop('disabled')).toEqual(true); + it('should render email form by default', () => { + const { container } = createWrapper(initialStore); + expect(container.querySelector(`.${styles.title}`)?.textContent).toEqual('Subscribe to replies'); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); }); it('should render subscribed state if user subscribed', () => { - const store = mockStore({ ...initialStore, user: { email_subscription: true } }); - const wrapper = createWrapper(store); + const { container } = createWrapper({ ...initialStore, user: { ...user, email_subscription: true } }); - expect(wrapper.find(`.${styles.subscribed}`)).toHaveLength(1); - expect(wrapper.text().startsWith('You are subscribed on updates by email')).toBe(true); + expect(container.querySelectorAll(`.${styles.subscribed}`)).toHaveLength(1); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(container.textContent?.startsWith('You are subscribed on updates by email')).toBe(true); }); it('should pass through subscribe process', async () => { - const wrapper = createWrapper(); + const { container } = createWrapper(); + const form = container.querySelector('form') as HTMLFormElement; + const input = container.querySelector('input') as HTMLInputElement; - const input = wrapper.find('input'); - const form = wrapper.find('form'); - - input.getDOMNode().value = 'some@email.com'; - input.simulate('input'); - form.simulate('submit'); + fireEvent.input(input, { target: { value: 'some@email.com' } }); + fireEvent.submit(form); expect(emailVerificationForSubscribeMock).toHaveBeenCalledWith('some@email.com'); - await sleep(); - wrapper.update(); + await act(() => sleep(0)); - const textarea = wrapper.find('textarea'); - const button = wrapper.find('button'); + // order matters here, and a lookup by name would not catch a swap + const buttons = container.querySelectorAll('button'); - expect(button.at(0).text()).toEqual('Back'); - expect(button.at(1).text()).toEqual('Subscribe'); + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent).toBe('Back'); + expect(buttons[1].textContent).toBe('Subscribe'); - textarea.getDOMNode().value = 'tokentokentoken'; - textarea.simulate('input'); - form.simulate('submit'); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + + fireEvent.input(textarea, { target: { value: 'tokentokentoken' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); expect(emailConfirmationForSubscribeMock).toHaveBeenCalledWith('tokentokentoken'); - await sleep(0); - wrapper.update(); + await act(() => sleep(0)); - expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true); - expect(wrapper.find(Button).text()).toEqual('Unsubscribe'); + expect(container.textContent?.startsWith('You have been subscribed on updates by email')).toBe(true); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Unsubscribe' })).toBeTruthy(); }); it('should handle http error 409: already subscribed', async () => { emailVerificationForSubscribeMock.mockImplementationOnce(() => Promise.reject(new RequestError('', 409))); - const wrapper = createWrapper(); + const { container } = createWrapper(); + const form = container.querySelector('form') as HTMLFormElement; + const input = container.querySelector('input') as HTMLInputElement; - const input = wrapper.find('input'); - const form = wrapper.find('form'); + fireEvent.input(input, { target: { value: 'some@email.com' } }); + fireEvent.submit(form); - input.getDOMNode().value = 'some@email.com'; - input.simulate('input'); - form.simulate('submit'); + await act(() => sleep(0)); - await sleep(); - wrapper.update(); - - expect(wrapper.text().startsWith('You are subscribed on updates by email')).toBe(true); + expect(container.textContent?.startsWith('You are subscribed on updates by email')).toBe(true); }); it('should pass through subscribe process without confirmation', async () => { @@ -165,74 +141,63 @@ describe('', () => { Promise.resolve({ address: email, updated: true }) ); - const wrapper = createWrapper(); + const { container } = createWrapper(); + const form = container.querySelector('form') as HTMLFormElement; + const input = container.querySelector('input') as HTMLInputElement; - const input = wrapper.find('input'); - const form = wrapper.find('form'); - - input.getDOMNode().value = 'some@email.com'; - input.simulate('input'); - form.simulate('submit'); + fireEvent.input(input, { target: { value: 'some@email.com' } }); + fireEvent.submit(form); expect(emailVerificationForSubscribeMock).toHaveBeenCalledWith('some@email.com'); - await sleep(); - wrapper.update(); + await act(() => sleep(0)); - expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true); - expect(wrapper.find(Button).text()).toEqual('Unsubscribe'); + expect(container.textContent?.startsWith('You have been subscribed on updates by email')).toBe(true); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Unsubscribe' })).toBeTruthy(); }); it('should fill in email from local storage', async () => { const expected = 'someone@email.com'; - persistEmail(expected); - const wrapper = createWrapper(); - const form = wrapper.find('form'); - expect(form.find('input').props().value).toBe(expected); + persistEmail(expected); + + const { container } = createWrapper(); + + expect(container.querySelector('input')?.value).toBe(expected); }); it('should send form by paste valid token', async () => { - const wrapper = createWrapper(); - const onInputEmail = wrapper.find(Input).prop('onInput') as Function; - const form = wrapper.find('form'); + const { container } = createWrapper(); + const input = container.querySelector('input') as HTMLInputElement; - expect(typeof onInputEmail === 'function').toBe(true); + fireEvent.input(input, { target: { value: 'some@email.com' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); - act(() => onInputEmail(makeInputEvent('some@email.com'))); + await act(() => sleep(0)); - form.simulate('submit'); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; - await sleep(0); - wrapper.update(); + fireEvent.input(textarea, { target: { value: validToken } }); - const textarea = wrapper.find('textarea'); + await act(() => sleep(0)); - textarea.getDOMNode().value = validToken; - textarea.simulate('input'); - - await sleep(0); - wrapper.update(); - - expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true); - expect(wrapper.find(Button).text()).toEqual('Unsubscribe'); + expect(container.textContent?.startsWith('You have been subscribed on updates by email')).toBe(true); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Unsubscribe' })).toBeTruthy(); }); it('should pass throw unsubscribe process', async () => { - const store = mockStore({ ...initialStore, user: { email_subscription: true } }); - const wrapper = createWrapper(store); - const onClick = wrapper.find(Button).prop('onClick') as Function; + const { container } = createWrapper({ ...initialStore, user: { ...user, email_subscription: true } }); - expect(typeof onClick === 'function').toBe(true); - - act(() => onClick()); + fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe' })); expect(unsubscribeFromEmailUpdatesMock).toHaveBeenCalled(); - await sleep(0); - wrapper.update(); + await act(() => sleep(0)); - expect(wrapper.text().startsWith('You have been unsubscribed by email to updates')).toBe(true); - expect(wrapper.find(Button).text()).toEqual('Close'); + expect(container.textContent?.startsWith('You have been unsubscribed by email to updates')).toBe(true); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy(); }); }); diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx index 63e51e82..b1c7fec7 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -2,8 +2,8 @@ import { h, FunctionComponent, Fragment } from 'preact'; import { useState, useCallback, useRef } from 'preact/hooks'; import { useSelector, useDispatch } from 'store/context'; import clsx from 'clsx'; -import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'common/intl'; import { User } from 'common/types'; import { StoreState } from 'store'; import { setUserSubscribed } from 'store/user/actions'; diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx index 8b0eb867..ba9854aa 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx @@ -1,34 +1,33 @@ -import { shallow } from 'enzyme'; +import { fireEvent, screen } from '@testing-library/preact'; + +import { render } from 'tests/utils'; import { SubscribeByRSS, createSubscribeUrl } from '.'; import styles from './subscribe-by-rss.module.css'; -jest.mock('store/context', () => ({ - useSelector: jest.fn((fn) => fn({ theme: 'light' })), -})); +/** Renders the widget and opens the dropdown, since the links only exist while it is open. */ +function renderOpened() { + const result = render(, { theme: 'light' }); -jest.mock('react-intl', () => { - const messages = require('locales/en.json'); - const reactIntl = jest.requireActual('react-intl'); - const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {}); + // the toggle is the only button until the dropdown opens, so this does not depend + // on the button's title copy + fireEvent.click(screen.getByRole('button')); - return { - ...reactIntl, - useIntl: () => intlProvider.state.intl, - }; -}); + return result; +} describe('', () => { it('should be render links in dropdown', () => { - const wrapper = shallow(); + const { container } = renderOpened(); - expect(wrapper.find(`.${styles.link}`)).toHaveLength(3); + expect(container.querySelectorAll(`.${styles.link}`)).toHaveLength(3); }); it('should have userId in replies link', () => { - const wrapper = shallow(); + const { container } = renderOpened(); + const links = container.querySelectorAll(`.${styles.link}`); - expect(wrapper.find(`.${styles.link}`).at(2).prop('href')).toBe(createSubscribeUrl('reply', '&user=user-1')); + expect(links[2].getAttribute('href')).toBe(createSubscribeUrl('reply', '&user=user-1')); }); }); diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx index 529d7639..c9d57b22 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx @@ -1,7 +1,7 @@ import { h, FunctionComponent } from 'preact'; import { useMemo } from 'preact/hooks'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages } from 'common/intl'; import { useTheme } from 'hooks/useTheme'; import { siteId, url } from 'common/settings'; import { BASE_URL, API_BASE } from 'common/constants'; diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx index 410f84af..f55cf0c8 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx @@ -2,8 +2,8 @@ import clsx from 'clsx'; import { h, FunctionComponent, Fragment } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { useSelector } from 'store/context'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages } from 'common/intl'; import { User } from 'common/types'; import { StoreState } from 'store'; import { FetcherError, RequestError, extractErrorMessageFromResponse } from 'utils/errorUtils'; diff --git a/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx b/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx index 5ff7c37d..6b38300c 100644 --- a/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx +++ b/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx @@ -1,7 +1,7 @@ import '@testing-library/jest-dom'; import { fireEvent, screen, waitFor } from '@testing-library/preact'; -import { useIntl } from 'react-intl'; +import { useIntl } from 'common/intl'; import { render } from 'tests/utils'; import { StaticStore } from 'common/static-store'; import * as localStorageModule from 'common/local-storage'; diff --git a/frontend/apps/remark42/app/components/comment-form/comment-form.tsx b/frontend/apps/remark42/app/components/comment-form/comment-form.tsx index 22c6e481..9e0f8e3f 100644 --- a/frontend/apps/remark42/app/components/comment-form/comment-form.tsx +++ b/frontend/apps/remark42/app/components/comment-form/comment-form.tsx @@ -1,7 +1,7 @@ import { h, Component, createRef, Fragment } from 'preact'; -import { FormattedMessage, IntlShape, defineMessages } from 'react-intl'; import clsx from 'clsx'; +import { FormattedMessage, IntlShape, defineMessages } from 'common/intl'; import { User, Theme, Image } from 'common/types'; import { StaticStore } from 'common/static-store'; import * as settings from 'common/settings'; @@ -454,7 +454,7 @@ export class CommentForm extends Component { { +export const Counter: FunctionComponent = ({ children }) => { return (
{children} diff --git a/frontend/apps/remark42/app/components/profile/profile.tsx b/frontend/apps/remark42/app/components/profile/profile.tsx index ae15b5ed..ce189d8f 100644 --- a/frontend/apps/remark42/app/components/profile/profile.tsx +++ b/frontend/apps/remark42/app/components/profile/profile.tsx @@ -1,8 +1,8 @@ import clsx from 'clsx'; import { h, Fragment } from 'preact'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import { useIntl, FormattedMessage } from 'react-intl'; +import { useIntl, FormattedMessage } from 'common/intl'; import { getUserComments } from 'common/api'; import { parseQuery } from 'utils/parse-query'; import { requestDeletion } from 'utils/email'; diff --git a/frontend/apps/remark42/app/components/root/root.tsx b/frontend/apps/remark42/app/components/root/root.tsx index ab29254b..9b25fab8 100644 --- a/frontend/apps/remark42/app/components/root/root.tsx +++ b/frontend/apps/remark42/app/components/root/root.tsx @@ -1,8 +1,8 @@ import { h, Component, Fragment } from 'preact'; import { useSelector } from 'store/context'; -import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl'; import clsx from 'clsx'; +import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'common/intl'; import 'styles/global.css'; import type { StoreState } from 'store'; import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, THEMES, IS_MOBILE } from 'common/constants'; diff --git a/frontend/apps/remark42/app/components/settings/settings.tsx b/frontend/apps/remark42/app/components/settings/settings.tsx index 908ca0cc..0a4ec113 100644 --- a/frontend/apps/remark42/app/components/settings/settings.tsx +++ b/frontend/apps/remark42/app/components/settings/settings.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import { User, BlockedUser, Theme, BlockTTL } from 'common/types'; import { getHandleClickProps } from 'common/accessibility'; import { StoreState } from 'store'; -import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'common/intl'; import { useTheme } from 'hooks/useTheme'; import styles from './settings.module.css'; diff --git a/frontend/apps/remark42/app/components/sort-picker.spec.tsx b/frontend/apps/remark42/app/components/sort-picker.spec.tsx index 944afd5d..8367a4ed 100644 --- a/frontend/apps/remark42/app/components/sort-picker.spec.tsx +++ b/frontend/apps/remark42/app/components/sort-picker.spec.tsx @@ -37,10 +37,7 @@ describe('', () => { expect(select).toBeInTheDocument(); - // @testing-library/preact rewrites change to input for every element once it sees a - // compat vnode, so fireEvent.change never reaches a select handler; dispatch directly - select.value = nextOption; - fireEvent(select, new Event('change', { bubbles: true })); + fireEvent.change(select, { target: { value: nextOption } }); await waitFor(() => expect(updateSorting).toHaveBeenCalledWith(nextOption)); diff --git a/frontend/apps/remark42/app/components/sort-picker.tsx b/frontend/apps/remark42/app/components/sort-picker.tsx index bd099425..2cc71d8d 100644 --- a/frontend/apps/remark42/app/components/sort-picker.tsx +++ b/frontend/apps/remark42/app/components/sort-picker.tsx @@ -1,7 +1,7 @@ import { h } from 'preact'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { useMemo } from 'preact/hooks'; +import { FormattedMessage, defineMessages, useIntl } from 'common/intl'; import { StoreState, useAppDispatch, useAppSelector } from 'store'; import { Select } from 'components/select'; import { updateSorting } from 'store/comments/actions'; diff --git a/frontend/apps/remark42/app/components/spinner/spinner.tsx b/frontend/apps/remark42/app/components/spinner/spinner.tsx index 66edc300..87fc96b2 100644 --- a/frontend/apps/remark42/app/components/spinner/spinner.tsx +++ b/frontend/apps/remark42/app/components/spinner/spinner.tsx @@ -1,8 +1,8 @@ import clsx from 'clsx'; import { h } from 'preact'; import { messages } from 'components/auth/auth.messages'; -import { useIntl } from 'react-intl'; +import { useIntl } from 'common/intl'; import styles from './spinner.module.css'; type Props = { diff --git a/frontend/apps/remark42/app/components/telegram/telegram-link.tsx b/frontend/apps/remark42/app/components/telegram/telegram-link.tsx index 48bb1c96..2668b92f 100644 --- a/frontend/apps/remark42/app/components/telegram/telegram-link.tsx +++ b/frontend/apps/remark42/app/components/telegram/telegram-link.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx'; import { h, Fragment, FunctionComponent } from 'preact'; -import { useIntl } from 'react-intl'; import { messages } from './telegram.messages'; import { BASE_URL, API_BASE } from 'common/constants.config'; import { Button } from 'components/button'; +import { useIntl } from 'common/intl'; import styles from './telegram-link.module.css'; export type TelegramLinkProps = { diff --git a/frontend/apps/remark42/app/components/telegram/telegram.messages.ts b/frontend/apps/remark42/app/components/telegram/telegram.messages.ts index 7134f323..33ad9e03 100644 --- a/frontend/apps/remark42/app/components/telegram/telegram.messages.ts +++ b/frontend/apps/remark42/app/components/telegram/telegram.messages.ts @@ -1,4 +1,4 @@ -import { defineMessages } from 'react-intl'; +import { defineMessages } from 'common/intl'; export const messages = defineMessages({ telegramMessage1: { diff --git a/frontend/apps/remark42/app/components/textarea-autosize.spec.tsx b/frontend/apps/remark42/app/components/textarea-autosize.spec.tsx new file mode 100644 index 00000000..d9cda05e --- /dev/null +++ b/frontend/apps/remark42/app/components/textarea-autosize.spec.tsx @@ -0,0 +1,21 @@ +import { createRef } from 'preact'; +import { render } from '@testing-library/preact'; + +import { TextareaAutosize } from './textarea-autosize'; + +describe('', () => { + it('points the given ref at the textarea', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLTextAreaElement); + expect(ref.current?.value).toBe('hello'); + }); + + it('works without a ref', () => { + const { container } = render(); + + expect(container.querySelector('textarea')?.value).toBe('hi'); + }); +}); diff --git a/frontend/apps/remark42/app/components/textarea-autosize.tsx b/frontend/apps/remark42/app/components/textarea-autosize.tsx index 1a1cb0d4..133b6898 100644 --- a/frontend/apps/remark42/app/components/textarea-autosize.tsx +++ b/frontend/apps/remark42/app/components/textarea-autosize.tsx @@ -1,20 +1,20 @@ -import { h, JSX, type TextareaHTMLAttributes } from 'preact'; -import { forwardRef } from 'preact/compat'; -import { useEffect, useImperativeHandle, useRef } from 'preact/hooks'; +import { h, JSX, type RefObject, type TextareaHTMLAttributes } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; function autoResize(textarea: HTMLTextAreaElement) { textarea.style.height = ''; textarea.style.height = `${textarea.scrollHeight}px`; } -type Props = Omit, 'onInput'> & { +type Props = Omit, 'onInput' | 'ref'> & { onInput?(evt: JSX.TargetedEvent): void; + /** Taken as a plain prop rather than through forwardRef, which lives only in preact/compat. */ + textareaRef?: RefObject; }; -export const TextareaAutosize = forwardRef(({ onInput, value, ...props }, externalRef) => { - const ref = useRef(null); - - useImperativeHandle(externalRef, () => ref.current as HTMLTextAreaElement, []); +export function TextareaAutosize({ onInput, value, textareaRef, ...props }: Props) { + const localRef = useRef(null); + const ref = textareaRef ?? localRef; const handleInput: JSX.GenericEventHandler = (evt) => { if (!ref.current) { @@ -32,7 +32,7 @@ export const TextareaAutosize = forwardRef(({ onInpu if (ref.current) { autoResize(ref.current); } - }, [value]); + }, [value, ref]); return