diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 95944b652..2cb857975 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,9 +14,10 @@ updates: # single bumps: patch and minor updates travel together and pass the # same checks once. Majors stay single so each gets its own review. # - # The Next.js line travels alone: while a 16.4 patch is still being - # re-measured, a red Next bump inside the group holds every harmless - # update in it red as well. + # The Next.js line travels alone. Its bumps are the ones that move + # URL state after history traversal (see the note on 16.4 below), and a + # red Next bump inside the group would hold every harmless update in + # it red as well. production-weekly: dependency-type: "production" update-types: @@ -32,19 +33,18 @@ updates: exclude-patterns: - "eslint-config-next" - "@next/bundle-analyzer" - ignore: - # Next 16.4.0 breaks URL state after history traversal: with a sheet - # opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes - # do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40 - # repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else - # equal). 16.3.8 carries every published security fix, so the line - # waits for a 16.4 patch; the two packages that move with it wait too. - - dependency-name: "next" - versions: ["16.4.0"] - - dependency-name: "eslint-config-next" - versions: ["16.4.0"] - - dependency-name: "@next/bundle-analyzer" - versions: ["16.4.0"] + # No ignore for the Next.js line any more. 16.4.0 lost URL state after + # history traversal: with a sheet opened through `?doc=`, Back left it + # open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated + # runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the + # same (the day keys lost a step in 2 of 20 runs locally, the `?doc=` + # journey failed once in CI). Both were ours to fix: the sheet followed + # `useSearchParams`, which 16.4 updates after the write it copies, and a + # day step wrote the URL before Next had adopted the previous step, whose + # late adoption then put the older URL back. The sheet now follows the + # address bar and a step waits for the layer. Measured after both fixes: + # 200 of 200 repeated runs of the three journeys, and another 80 of 80 + # each for the `?doc=` and day-key journeys. - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0f73c02..0b09a56d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [1.44.1] — 2026-10-22 + +### Security + +- **Next.js 16.4.2.** The framework fixed two critical advisories that + allow unauthenticated remote code execution, one in `use cache` functions + that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image + optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity + denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog + uses neither `use cache` nor the image component, and it defines no Server + Actions and refuses requests that call one. Every instance runs the same + framework, though, so it updates anyway. +- **The image optimizer is off.** Nothing in HealthLog renders through it, + so `/_next/image` now answers 404 instead of processing whatever image a + request names. Icons and logos load as before. +- **nodemailer 10.1.7** fixes several advisories in how email addresses are + parsed (GHSA-8c8m-p3m8-cjg8 among them). + +### Fixed + +- **`[` and `]` move the open day on every press.** Pressed quickly in a + row, one press could be lost and the day ended one short. A step now + starts from the day in the address bar and waits until the previous one + is shown. +- **Back closes a document opened from the vault.** Pressed right after + opening a document, Back could leave its sheet open. The sheet now follows + the address bar directly. + +### Upgrade notes + +- None. Pull the new image and restart. + ## [1.44.0] — 2026-10-22 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6cc0471ec..da8fbac66 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.44.0 + version: 1.44.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/next.config.ts b/next.config.ts index a844e2081..055b1dcf7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -35,6 +35,16 @@ const nextConfig: NextConfig = { // still covers the full program. typescript: { tsconfigPath: "tsconfig.build.json" }, poweredByHeader: false, + // The image optimizer is off. Nothing renders through `next/image`: logos, + // icons and avatars are plain `` elements served from `public/` or + // from an authenticated route. With the optimizer on, `/_next/image` still + // fetched, decoded and re-encoded whatever local path a request named, + // which is surface this app has no use for and where the framework has + // shipped critical advisories. Off, Next answers that route with 404 + // before it reads a single parameter. `next-image-optimizer-guard.test.ts` + // holds this: it fails when `next/image` is imported while the optimizer + // is off, and when the optimizer comes back with SVG input allowed. + images: { unoptimized: true }, // v1.4.38.4 — expose the package.json version to the client bundle // so the `` can compare the shell-baked version // against the live `/api/version` response and trigger a self-heal diff --git a/package.json b/package.json index 15a5b2028..5f044d763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.44.0", + "version": "1.44.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", @@ -79,8 +79,8 @@ "jspdf-autotable": "^5.0.8", "lucide-react": "^1.56.0", "mmdb-lib": "^3.0.3", - "next": "16.3.8", - "nodemailer": "10.1.0", + "next": "16.4.2", + "nodemailer": "10.1.7", "otpauth": "^9.5.2", "p-limit": "^7.3.3", "pg": "^8.23.1", @@ -106,7 +106,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.13.0", - "@next/bundle-analyzer": "^16.3.8", + "@next/bundle-analyzer": "16.4.2", "@playwright/test": "^1.64.0", "@tailwindcss/postcss": "^4", "@tesseract.js-data/deu": "1.0.0", @@ -121,7 +121,7 @@ "@vitest/coverage-v8": "^5.0.4", "ajv": "^8.20.0", "eslint": "^9", - "eslint-config-next": "16.3.8", + "eslint-config-next": "16.4.2", "knip": "^6.41.0", "lefthook": "^2.2.2", "pdf-parse": "^2.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a51aab68..a33b8b3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,11 +120,11 @@ importers: specifier: ^3.0.3 version: 3.0.3 next: - specifier: 16.3.8 - version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 16.4.2 + version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nodemailer: - specifier: 10.1.0 - version: 10.1.0 + specifier: 10.1.7 + version: 10.1.7 otpauth: specifier: ^9.5.2 version: 9.5.2 @@ -196,8 +196,8 @@ importers: specifier: ^4.13.0 version: 4.13.0(playwright-core@1.64.0) '@next/bundle-analyzer': - specifier: ^16.3.8 - version: 16.3.8 + specifier: 16.4.2 + version: 16.4.2 '@playwright/test': specifier: ^1.64.0 version: 1.64.0 @@ -241,8 +241,8 @@ importers: specifier: ^9 version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0) eslint-config-next: - specifier: 16.3.8 - version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + specifier: 16.4.2 + version: 16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) knip: specifier: ^6.41.0 version: 6.41.0 @@ -1075,13 +1075,6 @@ packages: resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.2.4': - resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@napi-rs/wasm-runtime@1.2.5': resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1089,63 +1082,63 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/bundle-analyzer@16.3.8': - resolution: {integrity: sha512-8VBk5/pjANz7fxbQZrDwUMTAs598Vckb4PE7BSYrDIPthJskKCXEHOK+WEMgDD6X2xWlKjrXPo2r41N/G1azSw==} + '@next/bundle-analyzer@16.4.2': + resolution: {integrity: sha512-tpNTC/WZYsmmixg3MZfx5JUuTh+Sgryu4eKhe7leQRcXHabztqtTkPCQM1Bk1VsE0trZ3w7NUCC3Zf7SeIBVzg==} - '@next/env@16.3.8': - resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} + '@next/env@16.4.2': + resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==} - '@next/eslint-plugin-next@16.3.8': - resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==} + '@next/eslint-plugin-next@16.4.2': + resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==} - '@next/swc-darwin-arm64@16.3.8': - resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} + '@next/swc-darwin-arm64@16.4.2': + resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.8': - resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} + '@next/swc-darwin-x64@16.4.2': + resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.8': - resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} + '@next/swc-linux-arm64-gnu@16.4.2': + resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.8': - resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} + '@next/swc-linux-arm64-musl@16.4.2': + resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.8': - resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} + '@next/swc-linux-x64-gnu@16.4.2': + resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.8': - resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} + '@next/swc-linux-x64-musl@16.4.2': + resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.8': - resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} + '@next/swc-win32-arm64-msvc@16.4.2': + resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.8': - resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} + '@next/swc-win32-x64-msvc@16.4.2': + resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3348,11 +3341,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.26: - resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.27: resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} engines: {node: '>=6.0.0'} @@ -3459,9 +3447,6 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001814: - resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} - caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} @@ -3961,8 +3946,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.3.8: - resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==} + eslint-config-next@16.4.2: + resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -5177,11 +5162,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5199,8 +5179,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@16.3.8: - resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} + next@16.4.2: + resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5241,8 +5221,8 @@ packages: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} - nodemailer@10.1.0: - resolution: {integrity: sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==} + nodemailer@10.1.7: + resolution: {integrity: sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==} engines: {node: '>=20.0.0'} non-error@0.1.0: @@ -7727,7 +7707,7 @@ snapshots: '@napi-rs/canvas-win32-arm64-msvc': 1.0.11 '@napi-rs/canvas-win32-x64-msvc': 1.0.11 - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -7748,44 +7728,44 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true - '@next/bundle-analyzer@16.3.8': + '@next/bundle-analyzer@16.4.2': dependencies: webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@next/env@16.3.8': {} + '@next/env@16.4.2': {} - '@next/eslint-plugin-next@16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))': + '@next/eslint-plugin-next@16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.8': + '@next/swc-darwin-arm64@16.4.2': optional: true - '@next/swc-darwin-x64@16.3.8': + '@next/swc-darwin-x64@16.4.2': optional: true - '@next/swc-linux-arm64-gnu@16.3.8': + '@next/swc-linux-arm64-gnu@16.4.2': optional: true - '@next/swc-linux-arm64-musl@16.3.8': + '@next/swc-linux-arm64-musl@16.4.2': optional: true - '@next/swc-linux-x64-gnu@16.3.8': + '@next/swc-linux-x64-gnu@16.4.2': optional: true - '@next/swc-linux-x64-musl@16.3.8': + '@next/swc-linux-x64-musl@16.4.2': optional: true - '@next/swc-win32-arm64-msvc@16.3.8': + '@next/swc-win32-arm64-msvc@16.4.2': optional: true - '@next/swc-win32-x64-msvc@16.3.8': + '@next/swc-win32-x64-msvc@16.4.2': optional: true '@noble/hashes@2.4.0': {} @@ -9625,7 +9605,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -9991,8 +9971,6 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.26: {} - baseline-browser-mapping@2.11.27: {} bcrypt-pbkdf@1.0.2: @@ -10120,8 +10098,6 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001814: {} - caniuse-lite@1.0.30001815: {} canvg@3.0.11: @@ -10644,9 +10620,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + eslint-config-next@16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0)) + '@next/eslint-plugin-next': 16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0)) eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0) eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) @@ -11907,8 +11883,6 @@ snapshots: nanoid@3.3.18: {} - nanoid@3.3.19: {} - nanoid@3.3.20: {} napi-postinstall@0.3.4: {} @@ -11917,25 +11891,25 @@ snapshots: negotiator@1.0.0: {} - next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.8 + '@next/env': 16.4.2 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.26 - caniuse-lite: 1.0.30001814 + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001815 postcss: 8.5.23 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.8 - '@next/swc-darwin-x64': 16.3.8 - '@next/swc-linux-arm64-gnu': 16.3.8 - '@next/swc-linux-arm64-musl': 16.3.8 - '@next/swc-linux-x64-gnu': 16.3.8 - '@next/swc-linux-x64-musl': 16.3.8 - '@next/swc-win32-arm64-msvc': 16.3.8 - '@next/swc-win32-x64-msvc': 16.3.8 + '@next/swc-darwin-arm64': 16.4.2 + '@next/swc-darwin-x64': 16.4.2 + '@next/swc-linux-arm64-gnu': 16.4.2 + '@next/swc-linux-arm64-musl': 16.4.2 + '@next/swc-linux-x64-gnu': 16.4.2 + '@next/swc-linux-x64-musl': 16.4.2 + '@next/swc-win32-arm64-msvc': 16.4.2 + '@next/swc-win32-x64-msvc': 16.4.2 '@opentelemetry/api': 1.9.1 '@playwright/test': 1.64.0 sharp: 0.35.5(@types/node@26.6.5) @@ -11959,7 +11933,7 @@ snapshots: node-releases@2.0.58: {} - nodemailer@10.1.0: {} + nodemailer@10.1.7: {} non-error@0.1.0: {} @@ -12300,7 +12274,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.19 + nanoid: 3.3.20 picocolors: 1.1.1 source-map-js: 1.2.2 diff --git a/public/sw.js b/public/sw.js index 58b5aebf1..9d6ebe388 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.44.0"; + /* @sw-version-fallback */ "v1.44.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/next-image-optimizer-guard.test.ts b/src/__tests__/next-image-optimizer-guard.test.ts new file mode 100644 index 000000000..a1732e4e6 --- /dev/null +++ b/src/__tests__/next-image-optimizer-guard.test.ts @@ -0,0 +1,110 @@ +/** + * The image optimizer stays off while nothing uses it. + * + * `next.config.ts` sets `images.unoptimized`, which makes Next answer + * `/_next/image` with 404 before it reads a parameter, so the route that + * fetches, decodes and re-encodes images is not reachable at all. Two ways + * to undo that quietly, and this pins both: + * + * 1. A component imports `next/image` (or `next/legacy/image`) while the + * optimizer is off. It would render, unoptimized, and the next person + * to notice the missing optimization would switch the optimizer back on + * without the review this flag deserves. Using `next/image` means + * revisiting this decision, so the import fails here first. + * 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a + * document, not a picture, and the framework's own advisories have been + * about exactly that input. Re-enabling the optimizer is allowed; doing + * it with SVG input allowed is not. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const ROOT = process.cwd(); +const SRC = join(ROOT, "src"); + +type ImagesConfig = { + unoptimized?: boolean; + dangerouslyAllowSVG?: boolean; +}; + +async function imagesConfig(): Promise { + const mod = (await import("../../next.config")) as { + default: { images?: ImagesConfig }; + }; + return mod.default.images ?? {}; +} + +// Any import of the module: static, dynamic, `require`, side effect or +// re-export. Two literals with one shape rather than one built from a string, +// so nothing has to be escaped into a pattern. +const NEXT_IMAGE_IMPORT = + /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/(?:legacy\/)?image["']/m; +const NEXT_LINK_IMPORT = + /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/link["']/m; + +/** Every non-generated, non-test source file under `src/` matching `re`. */ +function importers(re: RegExp): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .filter((rel) => !rel.includes("__tests__/")) + .filter((rel) => + re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))), + ); +} + +describe("next/image optimizer", () => { + it("the import matcher recognises every form of the import", () => { + // The sweep below passes when it matches nothing, so the matcher has to + // be shown to match before an empty result means anything. + for (const line of [ + 'import Image from "next/image";', + "import Image from 'next/legacy/image';", + 'const Image = await import("next/image");', + 'const Image = require("next/image");', + 'import "next/image";', + 'export { default } from "next/image";', + ]) { + expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true); + } + for (const line of [ + "// `next/image` is wrong here", + 'const path = "/_next/image?url=x";', + 'import { foo } from "next/image-extra";', + ]) { + expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false); + } + }); + + it("the same sweep finds the imports of a sibling module", () => { + // An empty result for `next/image` is only worth something if the walk, + // the comment stripping and the matcher together do find real imports. + // `next/link` is imported the same way all over the tree. + requireFloor("next/link importers", importers(NEXT_LINK_IMPORT), 100); + }); + + it("is off, and then nothing imports next/image", async () => { + const images = await imagesConfig(); + if (images.unoptimized !== true) return; + expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]); + }); + + it("is never on with SVG input allowed", async () => { + const images = await imagesConfig(); + if (images.unoptimized === true) return; + expect(images.dangerouslyAllowSVG ?? false).toBe(false); + }); + + it("is off today", async () => { + // The two cases above each skip on the other branch. This pins which + // branch the tree is on, so flipping it is a visible change to this + // file and not a silent switch between the two. + expect((await imagesConfig()).unoptimized).toBe(true); + }); +}); diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts index 32eef666c..dcfaaa23c 100644 --- a/src/components/day/__tests__/day-layer-controller.test.ts +++ b/src/components/day/__tests__/day-layer-controller.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; * * - opening adds exactly one entry, so Back closes the layer; * - opening another day while one is open swaps it in place; - * - stepping to the neighbouring day never adds an entry, however often; + * - stepping to the neighbouring day never adds an entry, however often, + * starts from the day in the URL and never passes today; * - closing a day the layer opened goes Back (the next Back leaves the * page); closing a deep link only drops the parameter and stays. */ @@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) { let browser: ReturnType; +const TODAY = "2026-01-10"; + beforeEach(() => { browser = installFakeBrowser("/insights/blood-pressure?range=90"); }); @@ -88,18 +91,63 @@ describe("day layer history", () => { it("steps without adding history entries", async () => { const { openDay, stepDay } = await import("../day-layer-controller"); openDay("2026-01-03"); - stepDay("2026-01-03", -1); - stepDay("2026-01-02", -1); - stepDay("2026-01-01", 1); + stepDay(-1, TODAY); + stepDay(-1, TODAY); + stepDay(1, TODAY); expect(browser.length()).toBe(2); expect(browser.current()).toContain("day=2026-01-02"); }); + it("steps from the day in the URL, so quick steps in a row all count", async () => { + // Nothing republishes the open day between these calls, which is what a + // fast key repeat looks like while the layer is still rendering the + // previous step. Each step must still move from where the last one went. + const { openDay, stepDay } = await import("../day-layer-controller"); + openDay("2026-01-03"); + expect(stepDay(1, TODAY)).toBe("2026-01-04"); + expect(stepDay(1, TODAY)).toBe("2026-01-05"); + expect(browser.current()).toContain("day=2026-01-05"); + }); + + it("holds a step while the layer still shows another day, then makes it", async () => { + // The layer publishes the day it shows only once Next has adopted the + // URL. A step written before that can be put back by Next's own write, + // so it waits for the layer and goes in one write when it catches up. + const { openDay, publishOpenDay, stepDay } = + await import("../day-layer-controller"); + openDay("2026-01-03"); + publishOpenDay("2026-01-03"); + expect(stepDay(-1, TODAY)).toBe("2026-01-02"); + // The layer still shows the 3rd: these two wait. + expect(stepDay(1, TODAY)).toBeNull(); + expect(stepDay(1, TODAY)).toBeNull(); + expect(browser.current()).toContain("day=2026-01-02"); + publishOpenDay("2026-01-02"); + expect(browser.current()).toContain("day=2026-01-04"); + expect(browser.length()).toBe(2); + publishOpenDay(null); + }); + + it("never steps past today, however often it is asked", async () => { + const { openDay, stepDay } = await import("../day-layer-controller"); + openDay("2026-01-09"); + expect(stepDay(1, TODAY)).toBe(TODAY); + expect(stepDay(1, TODAY)).toBeNull(); + expect(stepDay(1, TODAY)).toBeNull(); + expect(browser.current()).toContain(`day=${TODAY}`); + }); + + it("does nothing while no day is open", async () => { + const { stepDay } = await import("../day-layer-controller"); + expect(stepDay(-1, TODAY)).toBeNull(); + expect(browser.current()).toBe("/insights/blood-pressure?range=90"); + }); + it("closes a pushed day with Back, landing on the page without it", async () => { const { closeDay, openDay, stepDay } = await import("../day-layer-controller"); openDay("2026-01-03"); - stepDay("2026-01-03", 1); + stepDay(1, TODAY); closeDay(); expect(browser.current()).toBe("/insights/blood-pressure?range=90"); }); @@ -107,7 +155,7 @@ describe("day layer history", () => { it("closes a deep link in place: the parameter goes, the page stays", async () => { browser = installFakeBrowser("/?day=2026-01-03"); const { closeDay, stepDay } = await import("../day-layer-controller"); - stepDay("2026-01-03", -1); + stepDay(-1, TODAY); expect(browser.current()).toBe("/?day=2026-01-02"); closeDay(); expect(browser.current()).toBe("/"); diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index 1431ce594..6ce4eb5b7 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -2,11 +2,12 @@ import { useSyncExternalStore } from "react"; -import type { DateKey } from "@/lib/day/contract"; +import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract"; import { dayHistoryState, historyEntryOwnedByLayer, + parseDayParam, shiftDateKey, withDayHref, } from "./day-url"; @@ -100,6 +101,13 @@ export function publishOpenDay(date: DateKey | null): void { if (memory.open === date) return; memory.open = date; emit(); + // The layer has caught up with the URL: the steps that waited for it go + // now, in one write, from the day it shows. + const waiting = pendingStep; + pendingStep = null; + if (waiting !== null && date !== null) { + stepDay(waiting.delta, waiting.today); + } } /** @@ -155,9 +163,50 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void { } } -/** Move the open day by `delta` calendar days, without a new history entry. */ -export function stepDay(from: DateKey, delta: number): DateKey { - const next = shiftDateKey(from, delta); +/** + * Steps asked for while the layer had not yet shown the previous one. Kept + * as one sum, with the `today` of the latest request. + */ +let pendingStep: { delta: number; today: DateKey } | null = null; + +/** + * Move the open day by `delta` calendar days, without a new history entry, + * and never past `today`. Null when nothing is written now: no day is open, + * the step would pass today, or it waits for the previous one. + * + * Two things keep quick steps (a key held down, `]` pressed twice) from + * losing one. + * + * The step starts from the day in the URL, not from the day a caller last + * rendered, which reaches it through `useSearchParams`, a render and an + * effect and can be a step behind. + * + * And a step does not write while the layer still shows a different day + * than the URL. The write goes past Next's router, which adopts it a render + * later, and since Next 16.4 that render can land after a second write and + * put the first one's URL back: `[` then `]` pressed quickly ended a day + * short. So a step that finds the layer behind waits, and `publishOpenDay` + * makes it as soon as the layer shows the day the URL names. + */ +export function stepDay(delta: number, today: DateKey): DateKey | null { + if (typeof window === "undefined") return null; + const from = parseDayParam( + new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM), + today, + ); + if (from === null) { + pendingStep = null; + return null; + } + const total = (pendingStep?.delta ?? 0) + delta; + if (memory.open !== null && memory.open !== from) { + pendingStep = { delta: total, today }; + return null; + } + pendingStep = null; + const target = shiftDateKey(from, total); + const next = target > today ? today : target; + if (next === from) return null; jumpDay(next); return next; } diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index 8c75f2ea3..7a6676e14 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -348,8 +348,7 @@ function DayLayer() { const onStep = useCallback( (delta: number) => { if (date === null) return; - if (delta > 0 && date >= today) return; - stepDay(date, delta); + stepDay(delta, today); }, [date, today], ); diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 187ce48a0..ffbb2e535 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -31,7 +31,14 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; @@ -110,6 +117,26 @@ const BULK_VISIT_OPTIONS = 12; const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/; const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection"; +/** + * The query string in the address bar, read at render time. + * + * The open sheet follows `?doc=`, and `useSearchParams` is not a faithful + * copy of it: Next 16.4 applies the copy of a `pushState` later than the write, + * and a Back pressed in between moves the address bar from `?doc=` to the + * page without it while `useSearchParams` can read "no document" on both sides. + * The sheet then saw no change and stayed open. `window.location` is never + * behind; `popstate` re-reads it on every traversal, and every other render + * (including the ones Next's own navigations cause) reads it afresh. + */ +function subscribeToTraversal(onChange: () => void): () => void { + window.addEventListener("popstate", onChange); + return () => window.removeEventListener("popstate", onChange); +} + +function readLocationSearch(): string { + return window.location.search; +} + export function documentSelectionHistoryState( documentId: string, ): Record { @@ -818,7 +845,13 @@ export function DocumentsView() { const [detailOpen, setDetailOpen] = useState(false); const handedOffDocumentRef = useRef(null); - const rawDocParam = searchParams.get("doc"); + // Before hydration the server's view of the query stands in for the URL. + const locationSearch = useSyncExternalStore( + subscribeToTraversal, + readLocationSearch, + () => searchParams.toString(), + ); + const rawDocParam = new URLSearchParams(locationSearch).get("doc"); const docParam = rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam) ? rawDocParam @@ -837,14 +870,14 @@ export function DocumentsView() { const openDetail = useCallback( (id: string) => { - const href = documentSelectionHref(pathname, searchParams.toString(), id); + const href = documentSelectionHref(pathname, window.location.search, id); if (href === null) return; handedOffDocumentRef.current = null; setDetailId(id); setDetailOpen(true); window.history.pushState(documentSelectionHistoryState(id), "", href); }, - [pathname, searchParams], + [pathname], ); const handleDetailOpenChange = useCallback((open: boolean) => { @@ -881,7 +914,7 @@ export function DocumentsView() { handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff( window.history, pathname, - searchParams.toString(), + window.location.search, docParam, handedOffDocumentRef.current, coachLaunch?.closeIntent, @@ -925,7 +958,6 @@ export function DocumentsView() { docParam, moduleEnabled, pathname, - searchParams, ]); // Hover/focus intent prefetches the detail METADATA (never the blob — diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx index 71eb14229..1e5953c65 100644 --- a/src/components/layout/global-shortcuts.tsx +++ b/src/components/layout/global-shortcuts.tsx @@ -192,12 +192,12 @@ export function GlobalShortcuts() { setShortcutsHelpOpen(true); return; case "day": { - const date = state.openDay; - if (date === null) return; + if (state.openDay === null) return; event.preventDefault(); - // No day after today: the layer refuses it, so the step does too. - if (action.delta > 0 && date >= state.today) return; - stepDay(date, action.delta); + // The step reads the day from the URL, which is ahead of + // `openDay` while a previous step is still rendering, and refuses + // a day after today itself. + stepDay(action.delta, state.today); return; } case "day-toggle":