From 76af8680647e82bcc113700ed47408709506913e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:47 +0200 Subject: [PATCH 1/8] fix(day): step the open day from the URL, not from the last rendered day A step read the day the layer had last published, which reaches the shortcut and the header arrows through useSearchParams, a render and an effect. Next 16.4 lets that chain trail a replaceState by more than a key repeat, so two quick presses of ] both stepped from the same day and one was lost. stepDay now reads ?day= from window.location, which the step itself writes, and refuses a step past today on that value too. --- .../__tests__/day-layer-controller.test.ts | 41 ++++++++++++++++--- src/components/day/day-layer-controller.ts | 26 ++++++++++-- src/components/day/day-layer.tsx | 3 +- src/components/layout/global-shortcuts.tsx | 10 ++--- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts index 32eef666c..f950e9d2d 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,44 @@ 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("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 +136,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..28f5dd195 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"; @@ -155,8 +156,27 @@ 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 { +/** + * Move the open day by `delta` calendar days, without a new history entry. + * Null, and nothing written, when no day is open or the step would pass + * `today`. + * + * The step starts from the day in the URL at the moment of the step, not + * from the day a caller last rendered. The rendered day reaches a caller + * through Next's `useSearchParams`, a render of the layer and its + * `publishOpenDay`, and Next 16.4 lets that chain trail a `replaceState` by + * more than a key repeat: two quick `]` presses then both stepped from the + * same day and one of them was lost. `window.location` is written by the + * step itself, so it is never behind. + */ +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) return null; + if (delta > 0 && from >= today) return null; const next = shiftDateKey(from, delta); 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/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": From 44c2973a64194fe41b089a3bdc1a6d6a202997ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:48 +0200 Subject: [PATCH 2/8] fix(deps): update next to 16.4.2 and nodemailer to 10.1.7 Next 16.4.2 carries the fixes for GHSA-673q-mjm6-r5xr and GHSA-2464-fhr4-w9xw (unauthenticated remote code execution) and GHSA-p3m5-59mp-jv5r (denial of service through Server Actions). eslint-config-next and @next/bundle-analyzer move with it, both pinned exactly. nodemailer 10.1.7 fixes the address-parser advisories. The 16.4.0 ignore in the Dependabot config is gone: the history journeys it was guarding pass on 16.4.2 with the day-step fix, and the note says what was measured. --- .github/dependabot.yml | 29 ++++---- package.json | 8 +-- pnpm-lock.yaml | 156 +++++++++++++++++------------------------ 3 files changed, 82 insertions(+), 111 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 95944b652..7d9db7c92 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,15 @@ 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). Measured on + # 16.4.2: the `?doc=` and dashboard journeys passed 80 of 80, the day keys + # still lost a step in 2 of 20, because a step started from the day the + # layer had last rendered and 16.4 lets that trail the URL. A step now + # starts from the URL itself; after that the three journeys passed 200 of + # 200 repeated runs and the day keys alone another 80 of 80. - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/package.json b/package.json index 15a5b2028..6e5cbd564 100644 --- a/package.json +++ b/package.json @@ -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 From 1fa33f2aee8a7fa97b9e3bf7a13ee1ebdd95f15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:49 +0200 Subject: [PATCH 3/8] fix(security): turn the image optimizer off Nothing renders through next/image, so /_next/image only offered a route that fetches, decodes and re-encodes whatever local path a request names. With images.unoptimized Next answers it with 404 before reading a parameter. A guard fails when next/image is imported while the optimizer is off, or when the optimizer returns with SVG input allowed. --- next.config.ts | 10 ++ .../next-image-optimizer-guard.test.ts | 113 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/__tests__/next-image-optimizer-guard.test.ts 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/src/__tests__/next-image-optimizer-guard.test.ts b/src/__tests__/next-image-optimizer-guard.test.ts new file mode 100644 index 000000000..16f8a3124 --- /dev/null +++ b/src/__tests__/next-image-optimizer-guard.test.ts @@ -0,0 +1,113 @@ +/** + * 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 ?? {}; +} + +/** Matches any import of `specifier` (static, dynamic, `require`, re-export). */ +function importOf(specifier: string): RegExp { + const escaped = specifier.replace(/[/]/g, "\\/"); + return new RegExp( + `(?:\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|^\\s*import\\s+)["']${escaped}["']`, + "m", + ); +} + +const NEXT_IMAGE_IMPORT = importOf("next/(?:legacy/)?image"); + +/** 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(importOf("next/link")), 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); + }); +}); From 5902401ab8d95907186b6d1837ba1cc9c95514f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:49 +0200 Subject: [PATCH 4/8] chore(release): v1.44.1 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0f73c02..d7d276837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # 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, because a step started from the day still + on screen rather than the one the previous press had already opened. + +### 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/package.json b/package.json index 6e5cbd564..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", 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/*` From c496e390be6dcf8fd56971c2a6a640ccc283f287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 11:32:56 +0200 Subject: [PATCH 5/8] fix(day): hold a step until the layer shows the previous one A step writes ?day= with replaceState, past Next's router, which adopts the write a render later. Since 16.4 that render can land after a second quick step and write the first step's URL back, so [ then ] pressed in a row ended a day short. A step that finds the layer still showing another day than the URL now waits, and publishOpenDay makes it in one write as soon as the layer catches up. Never past today still holds for the sum. --- .../__tests__/day-layer-controller.test.ts | 19 +++++++ src/components/day/day-layer-controller.ts | 55 ++++++++++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts index f950e9d2d..dcfaaa23c 100644 --- a/src/components/day/__tests__/day-layer-controller.test.ts +++ b/src/components/day/__tests__/day-layer-controller.test.ts @@ -109,6 +109,25 @@ describe("day layer history", () => { 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"); diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index 28f5dd195..6ce4eb5b7 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -101,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); + } } /** @@ -157,17 +164,29 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void { } /** - * Move the open day by `delta` calendar days, without a new history entry. - * Null, and nothing written, when no day is open or the step would pass - * `today`. + * 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. * - * The step starts from the day in the URL at the moment of the step, not - * from the day a caller last rendered. The rendered day reaches a caller - * through Next's `useSearchParams`, a render of the layer and its - * `publishOpenDay`, and Next 16.4 lets that chain trail a `replaceState` by - * more than a key repeat: two quick `]` presses then both stepped from the - * same day and one of them was lost. `window.location` is written by the - * step itself, so it is never behind. + * 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; @@ -175,9 +194,19 @@ export function stepDay(delta: number, today: DateKey): DateKey | null { new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM), today, ); - if (from === null) return null; - if (delta > 0 && from >= today) return null; - const next = shiftDateKey(from, delta); + 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; } From 6cd8ffa52614c33fef55a7f53182e59d3cd719ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 11:32:57 +0200 Subject: [PATCH 6/8] fix(documents): follow the address bar for the open document The sheet compared useSearchParams with the last ?doc= it had seen. Next 16.4 applies its copy of a pushState after the write, and a Back pressed in between leaves useSearchParams reading no document on both sides, so the sheet saw no change and stayed open. The sheet now reads the query from window.location through useSyncExternalStore, re-read on popstate and on every render, and the selection and close paths build their URLs from the same live value. --- src/components/documents/documents-view.tsx | 44 ++++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) 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 — From aae7bce0788bf497d6fbc32d1b1364922f44e8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 11:32:57 +0200 Subject: [PATCH 7/8] test(security): write the next/image import matchers as literals --- .../next-image-optimizer-guard.test.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/__tests__/next-image-optimizer-guard.test.ts b/src/__tests__/next-image-optimizer-guard.test.ts index 16f8a3124..a1732e4e6 100644 --- a/src/__tests__/next-image-optimizer-guard.test.ts +++ b/src/__tests__/next-image-optimizer-guard.test.ts @@ -41,16 +41,13 @@ async function imagesConfig(): Promise { return mod.default.images ?? {}; } -/** Matches any import of `specifier` (static, dynamic, `require`, re-export). */ -function importOf(specifier: string): RegExp { - const escaped = specifier.replace(/[/]/g, "\\/"); - return new RegExp( - `(?:\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|^\\s*import\\s+)["']${escaped}["']`, - "m", - ); -} - -const NEXT_IMAGE_IMPORT = importOf("next/(?:legacy/)?image"); +// 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[] { @@ -89,7 +86,7 @@ describe("next/image optimizer", () => { // 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(importOf("next/link")), 100); + requireFloor("next/link importers", importers(NEXT_LINK_IMPORT), 100); }); it("is off, and then nothing imports next/image", async () => { From 03b3f6a3ba2085e4e4fabda8146b32397f163d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 11:32:57 +0200 Subject: [PATCH 8/8] docs: record the history fixes and what 16.4.2 measured --- .github/dependabot.yml | 15 +++++++++------ CHANGELOG.md | 8 ++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7d9db7c92..2cb857975 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,12 +36,15 @@ updates: # 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). Measured on - # 16.4.2: the `?doc=` and dashboard journeys passed 80 of 80, the day keys - # still lost a step in 2 of 20, because a step started from the day the - # layer had last rendered and 16.4 lets that trail the URL. A step now - # starts from the URL itself; after that the three journeys passed 200 of - # 200 repeated runs and the day keys alone another 80 of 80. + # 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 d7d276837..0b09a56d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,12 @@ ### Fixed - **`[` and `]` move the open day on every press.** Pressed quickly in a - row, one press could be lost, because a step started from the day still - on screen rather than the one the previous press had already opened. + 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