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; }