diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 47da072..0e1facc 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4687,10 +4687,11 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) = await expect(list.getByText('Fyrreskov syd')).toBeVisible(); await expect(list.getByText('Egeskov')).toHaveCount(0); - // Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente - // en søgning tilbage, man selv havde ryddet væk. + // Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` — + // den skrives kun af navigation. Den første udgave skrev den ved hvert + // tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se + // prøven nedenfor. await page.getByPlaceholder('Søg i noter').fill(''); - await expect(page).not.toHaveURL(/[?&]q=/); await expect(list.getByText('Egeskov')).toBeVisible(); expect(trouble).toEqual([]); @@ -4821,3 +4822,77 @@ test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => { expect(trouble).toEqual([]); }); +/** + * Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om. + * + * Fra en skærmoptagelse, to billeder i sekundet: + * + * 2,5 s feltet "verd" listen ufiltreret + * 3,0 s feltet "nde" listen filtreret + * 6,0 s feltet "verdande" listen ufiltreret + * 6,5 s feltet tomt listen filtreret + * + * To fejl oven i hinanden. + * + * Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk: + * `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt + * læste den forrige adresse og skrev den gamle værdi tilbage i feltet. + * + * Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på + * én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt. + */ +test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + // Sin egen note at finde. Et ord fra en anden prøve ville gøre den her + // afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil. + await page.getByRole('button', { name: 'Ny note' }).click(); + const krop = page.getByLabel('Notens tekst'); + await krop.fill('Kaffemølle\n\nnoget om den'); + await krop.blur(); + await expect(page.getByText('Gemt', { exact: true })).toBeVisible(); + await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible(); + + // Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der + // afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt. + const felt = page.getByPlaceholder('Søg i noter'); + await felt.click(); + await page.keyboard.type('kaffemølle', { delay: 40 }); + await expect(felt).toHaveValue('kaffemølle'); + + // Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det + // samme — adressen skal nå at komme tilbage, før den kan skrive over noget. + await page.waitForTimeout(1500); + await expect(felt).toHaveValue('kaffemølle'); + + // Det nyeste svar vinder, også når det kommer først. + // + // Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten + // lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der + // ikke længere bliver stillet. + let held; + await page.route('**/api/v1/notes?*', async (route) => { + const q = new URL(route.request().url()).searchParams.get('q'); + if (q === 'aaaaa' && !held) { + held = route; + return; // holdes, indtil den næste er kommet igennem + } + await route.continue(); + }); + + await felt.fill(''); + await felt.fill('aaaaa'); + await page.waitForTimeout(500); + await felt.fill('kaffemølle'); + await page.waitForTimeout(900); + if (held) await held.continue(); + await page.waitForTimeout(700); + + // Feltet og listen er enige: det, der står, er det, der blev spurgt om. + await expect(felt).toHaveValue('kaffemølle'); + await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible(); + + await page.unroute('**/api/v1/notes?*'); + expect(trouble).toEqual([]); +}); diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 15ea731..38a4204 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -15,7 +15,7 @@ import { app } from '$lib/stores.svelte.js'; import { page } from '$app/stores'; import { untrack } from 'svelte'; - import { goto, replaceState } from '$app/navigation'; + import { goto } from '$app/navigation'; import { t, tag } from '$lib/i18n.svelte.js'; import { NOTE, startDrag } from '$lib/dnd.js'; import { colorVar } from '$lib/colors.js'; @@ -485,48 +485,32 @@ }); /** - * En søgning, man kommer med, og en søgning, man kan gå videre med. + * En søgning, man kommer med. * - * ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede - * den noten på en side, hvis liste stod uændret. Så var de andre træffere væk, - * og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg - * får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg - * tilbage til den normale liste." + * ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre + * træffere frem for den almindelige liste. * - * Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre - * træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og - * deles — og frem for alt ikke lyver, når man har tømt feltet. + * Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i + * adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede + * bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, + * så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt + * den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man + * skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet. * - * `settled` er det, de to retninger er blevet enige om — og den læses med - * `untrack`, hvilket er hele forskellen på at virke og ikke. - * - * Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og - * `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor - * den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og - * skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen. - * Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig - * på den fælles markør. + * En markør, der skal holde to retninger enige, er en markør, der taber + * kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er + * den eneste, der læser den. */ - let settled = $state(null); + let cameWith = $state(null); $effect(() => { const inURL = $page.url.searchParams.get('q') ?? ''; - if (inURL === untrack(() => settled)) return; - settled = inURL; + if (inURL === untrack(() => cameWith)) return; + cameWith = inURL; draftQuery = inURL; query = inURL; }); - $effect(() => { - const now = query; - if (now === untrack(() => settled)) return; - settled = now; - const url = new URL(untrack(() => $page.url)); - if (now) url.searchParams.set('q', now); - else url.searchParams.delete('q'); - replaceState(url, {}); - }); - // Arriving from somewhere that names a note — a task's panel, a project's page — // opens it rather than dropping the person on a list to find it again. let asked = $derived($page.url.searchParams.get('note')); @@ -537,13 +521,30 @@ else api.note(asked).then(open).catch(() => {}); }); + /** + * Hvilken hentning der gælder. + * + * To søgninger kan være undervejs på én gang, og den langsomste kan lande + * sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der + * ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i + * listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne + * ikke gøre det umuligt. + */ + let newest = 0; + async function load(q) { + const mine = ++newest; try { const params = q ? { q } : showArchive ? { archived: '1' } : {}; - notes = (await api.notes(params)).notes ?? []; + const got = (await api.notes(params)).notes ?? []; + // Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert — + // det er bare ikke længere svaret på det, der blev spurgt om. + if (mine !== newest) return; + notes = got; status = 'ready'; if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null); } catch (e) { + if (mine !== newest) return; status = 'failed'; app.toast(humanMessage(e)); }