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.
This commit is contained in:
Dmitry Verkhoturov
2026-08-20 02:31:43 -05:00
committed by GitHub
parent 36062de0e7
commit b03dc366f9
19 changed files with 450 additions and 179 deletions
+20 -3
View File
@@ -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/`
+2 -2
View File
@@ -2,8 +2,8 @@ const getPresetEnv = (options) => ['@babel/preset-env', options];
const preactPreset = [
'@babel/preset-react',
{
pragma: 'h',
pragmaFrag: 'Fragment',
runtime: 'automatic',
importSource: 'preact',
},
];
+3 -3
View File
@@ -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',
@@ -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 } : {}),
@@ -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: {
@@ -265,7 +265,8 @@ describe('<Auth/>', () => {
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('<Auth/>', () => {
const input = screen.getByPlaceholderText('Username');
fireEvent.change(input, { target: { value } });
fireEvent.blur(input);
input.focus();
input.blur();
expect(input).toHaveValue(expected);
});
@@ -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<JSX.HTMLAttributes<HTMLButtonElement>, 'size'> & {
type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size'> & {
size?: 'xs' | 'sm';
kind?: 'transparent' | 'link' | 'hollow';
suffix?: VNode;
@@ -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<string, string> = {
large: styles.sizeLarge,
};
export type ButtonProps = Omit<JSX.HTMLAttributes, 'size' | 'className'> & {
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size' | 'className'> & {
kind?: 'primary' | 'secondary' | 'link';
size?: 'middle' | 'large';
theme?: Theme;
mix?: string | string[];
type?: string;
className?: string;
};
@@ -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<HTMLInputElement> & {
type Props = InputHTMLAttributes<HTMLInputElement> & {
invalid?: boolean;
};
@@ -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('<Select/>', () => {
it('should highlight select on focus', async () => {
render(<Select items={items} selected={items[0]} />);
fireEvent.focus(screen.getByRole('combobox'));
screen.getByRole<HTMLSelectElement>('combobox').focus();
await waitFor(() => {
const rootElement = screen.getByTestId('select-root');
expect(rootElement).toHaveClass('select_focused');
@@ -37,7 +37,10 @@ describe('<SortPicker />', () => {
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));
@@ -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<JSX.HTMLAttributes<HTMLTextAreaElement>, 'onInput'> & {
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onInput'> & {
onInput?(evt: JSX.TargetedEvent<HTMLTextAreaElement, Event>): void;
};
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, Props>(({ onInput, value, ...props }, externalRef) => {
const localRef = useRef<HTMLTextAreaElement>(null);
const ref = externalRef || localRef;
const ref = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(externalRef, () => ref.current as HTMLTextAreaElement, []);
const handleInput: JSX.GenericEventHandler<HTMLTextAreaElement> = (evt) => {
if (!ref.current) {
@@ -31,7 +32,7 @@ export const TextareaAutosize = forwardRef<HTMLTextAreaElement, Props>(({ onInpu
if (ref.current) {
autoResize(ref.current);
}
}, [value, ref]);
}, [value]);
return <textarea {...props} data-testid={props.id} onInput={handleInput} value={value} ref={ref} dir="auto" />;
});
@@ -1,4 +1,4 @@
import { h, FunctionComponent } from 'preact';
import { h, FunctionComponent, type AriaRole } from 'preact';
import { shallowEqual } from 'react-redux';
import { useCallback } from 'preact/hooks';
import clsx from 'clsx';
@@ -54,7 +54,7 @@ export const Thread: FunctionComponent<Props> = ({ id, level, getPreview }) => {
level === 6 && styles.level6,
theme === 'dark' && styles.themeDark
)}
role={['listitem'].concat(!collapsed && !!repliesCount ? 'list' : []).join(' ')}
role={(!collapsed && !!repliesCount ? 'listitem list' : 'listitem') as AriaRole}
aria-expanded={!collapsed}
>
<InView>
@@ -1,6 +1,6 @@
import { useState, StateUpdater } from 'preact/hooks';
import { useState, StateUpdater, Dispatch } from 'preact/hooks';
function useSessionStorage<T>(key: string, initialValue?: T): [T, StateUpdater<T>] {
function useSessionStorage<T>(key: string, initialValue?: T): [T, Dispatch<StateUpdater<T>>] {
const [storedValue, setStoredValue] = useState<T>(() => {
const item = sessionStorage.getItem(key);
if (item === null) {
+7 -7
View File
@@ -35,9 +35,9 @@
"intersection-observer": "^0.12.2",
"lodash-es": "^4.18.1",
"node-emoji": "^1.11.0",
"preact": "10.6.2",
"react": "npm:@preact/compat@^17.1.1",
"react-dom": "npm:@preact/compat@^17.1.1",
"preact": "10.29.8",
"react": "npm:@preact/compat@^18.3.2",
"react-dom": "npm:@preact/compat@^18.3.2",
"react-intl": "6.0.5",
"react-redux": "^8.0.2",
"redux": "^4.2.0",
@@ -58,7 +58,7 @@
"@swc/core": "1.2.205",
"@swc/jest": "^0.2.21",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/preact": "3.2.2",
"@testing-library/preact": "3.2.4",
"@testing-library/preact-hooks": "^1.1.0",
"@types/enzyme": "^3.10.19",
"@types/eslint": "^8.4.5",
@@ -70,8 +70,8 @@
"@types/redux-mock-store": "^1.5.0",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/webpack-env": "^1.18.8",
"@typescript-eslint/eslint-plugin": "^5.30.3",
"@typescript-eslint/parser": "^5.30.3",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"babel-loader": "^8.2.5",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"clean-webpack-plugin": "^4.0.0",
@@ -121,7 +121,7 @@
"ts-loader": "^9.6.2",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.7.4",
"typescript": "^5.9.3",
"url-loader": "^4.1.1",
"webpack": "^5.108.3",
"webpack-bundle-analyzer": "^4.5.0",
+2 -3
View File
@@ -4,9 +4,8 @@
"target": "es6",
"module": "esnext",
"lib": ["es2019", "dom"],
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"noEmit": true,
"strict": true,
"allowJs": true,
+3
View File
@@ -117,6 +117,9 @@ module.exports = (_, { mode, analyze }) => {
loader: 'ts-loader',
options: {
transpileOnly: true,
// tsconfig targets the automatic runtime so type checking resolves JSX from
// preact; the bundle keeps JSX intact here so babel can still strip test ids
compilerOptions: { jsx: 'preserve' },
},
},
],
+1 -1
View File
@@ -87,7 +87,7 @@
"svgo": ">=4.0.2 <5.0.0",
"undici@>=7.0.0 <8.0.0": ">=7.29.0 <8.0.0",
"body-parser@<1.20.6": ">=1.20.6 <2.0.0",
"preact": "10.6.2",
"preact": "10.29.8",
"@types/minimatch": "5.1.2",
"cheerio": "1.0.0-rc.12"
}
+383 -136
View File
File diff suppressed because it is too large Load Diff