From b03dc366f98c66c7622a4e60c54a0ba45e971392 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Thu, 20 Aug 2026 08:31:43 +0100 Subject: [PATCH] Update preact to 10.29.8 (#2163) * Update preact to 10.29.8 Also moves TypeScript to 5.9, which preact 10.29 typings require, and the compat and testing library pins that go with it. Type checking resolves JSX from preact via the automatic runtime; the bundle keeps the classic transform so babel still strips test ids. * Move babel to the automatic JSX runtime and refresh frontend notes Leaving babel on the classic h pragma while tsconfig used the automatic runtime meant a tsx file without an h import would type-check and lint clean, then throw at runtime, since eslint-config-preact disables react/react-in-jsx-scope and no-undef is off. * Address review findings on the preact upgrade Forward the textarea ref with useImperativeHandle so it clears on unmount and lands during commit rather than after paint. Pair typescript-eslint with the TypeScript it now has to parse. Use the preact namespace types rather than the deprecated JSX aliases, and drop the redundant type re-declarations the element-specific interfaces already provide. * Drive the focus tests through real DOM focus and blur Dispatching a synthetic focusin hard-coded preact/compat's internal alias for onFocus. Calling focus() and blur() exercises the sequence a browser produces and stays correct if that mapping changes. * Raise the two bundle limits the preact upgrade pushes past CI measures remark.mjs at 78024 bytes against a limit size-limit reads as 78000, so it failed by 24. last-comments.mjs had 36 bytes of headroom and would have tripped on the next change. * Regenerate the lockfile after the rebase The rebase resolution left it missing the @typescript-eslint entries, so every CI job failed at pnpm install --frozen-lockfile. --- frontend/CLAUDE.md | 23 +- frontend/apps/remark42/.babelrc.js | 4 +- frontend/apps/remark42/.size-limit.js | 6 +- .../apps/remark42/app/common/accessibility.ts | 2 +- .../apps/remark42/app/common/copy.test.ts | 2 +- .../app/components/auth/auth.spec.tsx | 6 +- .../app/components/auth/components/button.tsx | 4 +- .../remark42/app/components/button/button.tsx | 5 +- .../remark42/app/components/input/input.tsx | 4 +- .../app/components/select/select.spec.tsx | 4 +- .../app/components/sort-picker.spec.tsx | 5 +- .../app/components/textarea-autosize.tsx | 13 +- .../remark42/app/components/thread/thread.tsx | 4 +- .../remark42/app/hooks/useSessionState.ts | 4 +- frontend/apps/remark42/package.json | 14 +- frontend/apps/remark42/tsconfig.json | 5 +- frontend/apps/remark42/webpack.config.js | 3 + frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 519 +++++++++++++----- 19 files changed, 450 insertions(+), 179 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a0a04809..a4019386 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -19,8 +19,7 @@ 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: -- `preact` pinned via `pnpm.overrides` (10.6.2) — newer breaks the build under the stricter layout -- `react-intl` 6.0.5 and `@testing-library/preact` 3.2.2 — newer versions' type declarations break the preact-compat alias setup +- `react-intl` 6.0.5 — newer versions' type declarations break the preact-compat alias setup - `@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 @@ -32,12 +31,30 @@ Unlike the polyfilled fetch in node 16 and 18, it rejects relative request URLs, silent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and a jsdom base URL set. +## JSX runs on the automatic runtime, in three places that must agree + +`preact` 10.29 types a component's return as `ComponentChildren`, which only satisfies a JSX check on +TypeScript 5.1+ via `JSX.ElementType`, and it scopes the `JSX` namespace to `preact/jsx-runtime` rather +than declaring it globally. So the type layer has to use the automatic runtime: + +- `tsconfig.json`: `jsx: react-jsx` with `jsxImportSource: preact` +- `.babelrc.js`: `@babel/preset-react` with `runtime: 'automatic'`, `importSource: 'preact'` +- `jest.config.ts`: `@swc/jest` with `transform.react.runtime: 'automatic'`, `importSource: 'preact'` + +`ts-loader` in `webpack.config.js` overrides `jsx` back to `preserve`. That is deliberate: JSX has to +survive as JSX until babel runs, or `babel-plugin-jsx-remove-data-test-id` has nothing to strip and +`data-testid` attributes ship to production. Verify with `grep -c data-testid public/*.mjs` after a +production build; it must be 0. + +Keep all three in step. If babel alone were left on the classic `pragma: 'h'` transform, a new `.tsx` +without `import { h }` would type-check and lint clean, then throw at runtime, because +`eslint-config-preact` sets `react/react-in-jsx-scope` to 0 and the local config turns `no-undef` off. + ## Held-back majors 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) -- `typescript` 4.7 in `apps/remark42` specifically (the api package is on a newer TS — they're intentionally decoupled) - `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/` diff --git a/frontend/apps/remark42/.babelrc.js b/frontend/apps/remark42/.babelrc.js index 05fe0d12..6a86fa1e 100644 --- a/frontend/apps/remark42/.babelrc.js +++ b/frontend/apps/remark42/.babelrc.js @@ -2,8 +2,8 @@ const getPresetEnv = (options) => ['@babel/preset-env', options]; const preactPreset = [ '@babel/preset-react', { - pragma: 'h', - pragmaFrag: 'Fragment', + runtime: 'automatic', + importSource: 'preact', }, ]; diff --git a/frontend/apps/remark42/.size-limit.js b/frontend/apps/remark42/.size-limit.js index a40b622d..2036a041 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: '76 KB', + limit: '80 KB', }, { path: 'public/remark.css', @@ -13,7 +13,7 @@ module.exports = [ }, { path: 'public/last-comments.mjs', - limit: '39 KB', + limit: '40 KB', }, { path: 'public/last-comments.css', @@ -21,7 +21,7 @@ module.exports = [ }, { path: 'public/deleteme.mjs', - limit: '14 KB', + limit: '15 KB', }, { path: 'public/counter.mjs', diff --git a/frontend/apps/remark42/app/common/accessibility.ts b/frontend/apps/remark42/app/common/accessibility.ts index 8da58c35..73da7882 100644 --- a/frontend/apps/remark42/app/common/accessibility.ts +++ b/frontend/apps/remark42/app/common/accessibility.ts @@ -6,7 +6,7 @@ const handleBtnKeyPress = (event: KeyboardEvent, handler?: (e: KeyboardEvent | M }; export const getHandleClickProps = (handler?: (e: KeyboardEvent | MouseEvent) => void) => ({ - role: 'button', + role: 'button' as const, onClick: handler, onKeyPress: (event: KeyboardEvent) => handleBtnKeyPress(event, handler), ...(handler ? { tabIndex: 0 } : {}), diff --git a/frontend/apps/remark42/app/common/copy.test.ts b/frontend/apps/remark42/app/common/copy.test.ts index d6cb3dc1..4e9996f9 100644 --- a/frontend/apps/remark42/app/common/copy.test.ts +++ b/frontend/apps/remark42/app/common/copy.test.ts @@ -10,7 +10,7 @@ describe('copy to clipboard', () => { it('should call `clipboard.write` for new browser', async () => { const clipboardWrite = jest.fn(() => Promise.resolve()); - window.ClipboardItem = jest.fn(); + Object.defineProperty(window, 'ClipboardItem', { value: jest.fn(), writable: true }); Object.defineProperty(navigator, 'clipboard', { value: { diff --git a/frontend/apps/remark42/app/components/auth/auth.spec.tsx b/frontend/apps/remark42/app/components/auth/auth.spec.tsx index f1737189..33e5c27d 100644 --- a/frontend/apps/remark42/app/components/auth/auth.spec.tsx +++ b/frontend/apps/remark42/app/components/auth/auth.spec.tsx @@ -265,7 +265,8 @@ describe('', () => { const input = screen.getByPlaceholderText('Username'); fireEvent.change(input, { target: { value } }); - fireEvent.blur(input); + input.focus(); + input.blur(); expect(input).toHaveValue(expected); }); @@ -285,7 +286,8 @@ describe('', () => { const input = screen.getByPlaceholderText('Username'); fireEvent.change(input, { target: { value } }); - fireEvent.blur(input); + input.focus(); + input.blur(); expect(input).toHaveValue(expected); }); diff --git a/frontend/apps/remark42/app/components/auth/components/button.tsx b/frontend/apps/remark42/app/components/auth/components/button.tsx index e3110d8a..a1662c96 100644 --- a/frontend/apps/remark42/app/components/auth/components/button.tsx +++ b/frontend/apps/remark42/app/components/auth/components/button.tsx @@ -1,9 +1,9 @@ -import { h, JSX, VNode } from 'preact'; +import { h, VNode, type ButtonHTMLAttributes } from 'preact'; import clsx from 'clsx'; import styles from './button.module.css'; -type Props = Omit, 'size'> & { +type Props = Omit, 'size'> & { size?: 'xs' | 'sm'; kind?: 'transparent' | 'link' | 'hollow'; suffix?: VNode; diff --git a/frontend/apps/remark42/app/components/button/button.tsx b/frontend/apps/remark42/app/components/button/button.tsx index b58cef29..d675f370 100644 --- a/frontend/apps/remark42/app/components/button/button.tsx +++ b/frontend/apps/remark42/app/components/button/button.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { h, JSX } from 'preact'; +import { h, type ButtonHTMLAttributes } from 'preact'; import { forwardRef } from 'preact/compat'; import type { Theme } from 'common/types'; @@ -17,12 +17,11 @@ const sizeStyles: Record = { large: styles.sizeLarge, }; -export type ButtonProps = Omit & { +export type ButtonProps = Omit, 'size' | 'className'> & { kind?: 'primary' | 'secondary' | 'link'; size?: 'middle' | 'large'; theme?: Theme; mix?: string | string[]; - type?: string; className?: string; }; diff --git a/frontend/apps/remark42/app/components/input/input.tsx b/frontend/apps/remark42/app/components/input/input.tsx index 92cb3f1a..ccf90f0e 100644 --- a/frontend/apps/remark42/app/components/input/input.tsx +++ b/frontend/apps/remark42/app/components/input/input.tsx @@ -1,9 +1,9 @@ -import { h, JSX } from 'preact'; +import { h, type InputHTMLAttributes } from 'preact'; import clsx from 'clsx'; import styles from './input.module.css'; -type Props = JSX.HTMLAttributes & { +type Props = InputHTMLAttributes & { invalid?: boolean; }; diff --git a/frontend/apps/remark42/app/components/select/select.spec.tsx b/frontend/apps/remark42/app/components/select/select.spec.tsx index 229c88ae..71ee285f 100644 --- a/frontend/apps/remark42/app/components/select/select.spec.tsx +++ b/frontend/apps/remark42/app/components/select/select.spec.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { fireEvent, screen, waitFor } from '@testing-library/preact'; +import { screen, waitFor } from '@testing-library/preact'; import { render } from 'tests/utils'; import { Select } from './select'; @@ -31,7 +31,7 @@ describe('); - fireEvent.focus(screen.getByRole('combobox')); + screen.getByRole('combobox').focus(); await waitFor(() => { const rootElement = screen.getByTestId('select-root'); expect(rootElement).toHaveClass('select_focused'); diff --git a/frontend/apps/remark42/app/components/sort-picker.spec.tsx b/frontend/apps/remark42/app/components/sort-picker.spec.tsx index 8367a4ed..944afd5d 100644 --- a/frontend/apps/remark42/app/components/sort-picker.spec.tsx +++ b/frontend/apps/remark42/app/components/sort-picker.spec.tsx @@ -37,7 +37,10 @@ describe('', () => { expect(select).toBeInTheDocument(); - fireEvent.change(select, { target: { value: nextOption } }); + // @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 })); await waitFor(() => expect(updateSorting).toHaveBeenCalledWith(nextOption)); diff --git a/frontend/apps/remark42/app/components/textarea-autosize.tsx b/frontend/apps/remark42/app/components/textarea-autosize.tsx index e8c82437..1a1cb0d4 100644 --- a/frontend/apps/remark42/app/components/textarea-autosize.tsx +++ b/frontend/apps/remark42/app/components/textarea-autosize.tsx @@ -1,19 +1,20 @@ -import { h, JSX } from 'preact'; +import { h, JSX, type TextareaHTMLAttributes } from 'preact'; import { forwardRef } from 'preact/compat'; -import { useEffect, useRef } from 'preact/hooks'; +import { useEffect, useImperativeHandle, 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'> & { onInput?(evt: JSX.TargetedEvent): void; }; export const TextareaAutosize = forwardRef(({ onInput, value, ...props }, externalRef) => { - const localRef = useRef(null); - const ref = externalRef || localRef; + const ref = useRef(null); + + useImperativeHandle(externalRef, () => ref.current as HTMLTextAreaElement, []); const handleInput: JSX.GenericEventHandler = (evt) => { if (!ref.current) { @@ -31,7 +32,7 @@ export const TextareaAutosize = forwardRef(({ onInpu if (ref.current) { autoResize(ref.current); } - }, [value, ref]); + }, [value]); return