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