Breng de opstartkosten terug en geef de werkstand een schakelaar
De inlogpagina haalde 1,46 MB over de lijn, waaronder de volledige kaartmotor van 213 kB terwijl er geen kaart staat. De werkbank is nu een aparte brok die pas na aanmelden geladen wordt: de eerste JS-bundel gaat van 476 naar 30 kB, en met de beeldwerk uit een eerdere commit komt de inlogpagina op 344 kB. Bij het opstarten gingen 27 verzoeken de deur uit in drie golven. Alles werd vooraf opgehaald, ook voor werkbladen die de gebruiker misschien nooit opent, en twee daarvan werden dubbel opgevraagd door effecten die negentig milliseconde na elkaar liepen. Gelijktijdige en vlak opeenvolgende identieke GET-verzoeken delen nu één belofte, en elk werkblad haalt zijn eigen gegevens pas op wanneer het geopend wordt. Nu zestien verzoeken. Typen in het themazoekveld kostte 53 ms mediaan met uitschieters tot 127 ms, omdat die filterwaarde in het viewmodel van de hele werkruimte leefde. Het veld is een eigen component geworden dat zijn invoer zelf bijhoudt. React.memo om de kaart is geprobeerd en weer verwijderd: van de negentien props worden er te veel per render opnieuw gemaakt, dus de vergelijking sloeg nooit over. Verder: donker blijft de standaard maar er is nu een weg terug naar licht, de kaartondergrond schakelt mee zonder de datalagen te herbouwen, de onderste hero-afbeelding wordt pas opgehaald als hij in beeld komt, en de werkbank heeft een h1 zodat schermlezers een ankerpunt hebben. De tests op de bootstrap legden het oude gedrag vast en toetsen nu het nieuwe, van drie naar zeven gevallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apiGet, apiPost } from './client'
|
||||
|
||||
function antwoord(data: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data }),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('apiGet deelt verzoeken', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('stuurt één verzoek wanneer drie aanroepers tegelijk hetzelfde vragen', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [1] }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
apiGet<{ items: number[] }>('/api/v1/projects'),
|
||||
apiGet<{ items: number[] }>('/api/v1/projects'),
|
||||
apiGet<{ items: number[] }>('/api/v1/projects'),
|
||||
])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(a).toEqual({ items: [1] })
|
||||
expect(b).toBe(a)
|
||||
expect(c).toBe(a)
|
||||
})
|
||||
|
||||
it('deelt ook wanneer het tweede verzoek kort ná het eerste komt', async () => {
|
||||
// Dit was het echte geval bij het opstarten: twee effecten die ongeveer
|
||||
// negentig milliseconde na elkaar dezelfde gegevens opvroegen, dus de
|
||||
// eerste was al klaar voordat de tweede begon.
|
||||
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await apiGet('/api/v1/projects/p-1/datasets')
|
||||
await vi.advanceTimersByTimeAsync(90)
|
||||
await apiGet('/api/v1/projects/p-1/datasets')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('haalt opnieuw op zodra het deelvenster voorbij is', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await apiGet('/api/v1/projects/p-1/areas')
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
await apiGet('/api/v1/projects/p-1/areas')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('houdt verschillende paden uit elkaar', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await Promise.all([
|
||||
apiGet('/api/v1/projects'),
|
||||
apiGet('/api/v1/projects?name=Kempen&limit=1'),
|
||||
])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deelt geen POST — twee keer versturen is een andere handeling', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ ok: true }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await Promise.all([
|
||||
apiPost('/api/v1/projects', { name: 'a' }),
|
||||
apiPost('/api/v1/projects', { name: 'a' }),
|
||||
])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('laat een mislukt verzoek niet in de tabel achter', async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('netwerk weg'))
|
||||
.mockResolvedValue(antwoord({ items: [] }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await expect(apiGet('/api/v1/detection/models')).rejects.toThrow('netwerk weg')
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
await expect(apiGet('/api/v1/detection/models')).resolves.toEqual({ items: [] })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -31,9 +31,48 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gelijktijdige identieke GET-verzoeken worden gedeeld.
|
||||
*
|
||||
* Bij het opstarten vroegen drie verschillende plekken tegelijk om
|
||||
* /api/v1/projects, en areas en datasets elk twee keer — 27 verzoeken in totaal
|
||||
* waarvan vijf overbodig. Elke aanroeper hier krijgt dezelfde belofte zolang
|
||||
* het verzoek onderweg is; zodra het klaar is verdwijnt het uit de tabel, dus
|
||||
* er wordt niets gecachet en een volgende aanroep haalt gewoon opnieuw op.
|
||||
*
|
||||
* Alleen GET. Een POST twee keer versturen is een andere handeling en mag
|
||||
* nooit stilzwijgend samengevoegd worden.
|
||||
*/
|
||||
const lopendeGets = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Hoe lang een afgerond GET-verzoek nog gedeeld wordt.
|
||||
*
|
||||
* Puur gelijktijdige verzoeken samenvoegen was niet genoeg: bij het opstarten
|
||||
* vroegen twee verschillende effecten dezelfde gebieden en bronnen op met
|
||||
* ongeveer negentig milliseconde ertussen, dus was de eerste al klaar voordat
|
||||
* de tweede begon. Dit venster is kort genoeg om nooit verouderde gegevens te
|
||||
* tonen — een gebruiker die op "Vernieuwen" drukt zit er ruim boven — en lang
|
||||
* genoeg om de opstartcascade op te vangen.
|
||||
*/
|
||||
const DEEL_VENSTER_MS = 300;
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const response = await fetch(apiUrl(path), { credentials: "same-origin" });
|
||||
return parseResponse<T>(response);
|
||||
const lopend = lopendeGets.get(path);
|
||||
if (lopend) {
|
||||
return lopend as Promise<T>;
|
||||
}
|
||||
|
||||
const verzoek = fetch(apiUrl(path), { credentials: "same-origin" })
|
||||
.then((response) => parseResponse<T>(response))
|
||||
.finally(() => {
|
||||
window.setTimeout(() => {
|
||||
lopendeGets.delete(path);
|
||||
}, DEEL_VENSTER_MS);
|
||||
});
|
||||
|
||||
lopendeGets.set(path, verzoek);
|
||||
return verzoek;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body?: object): Promise<T> {
|
||||
|
||||
Reference in New Issue
Block a user