Skip to content

Commit 306dd99

Browse files
authored
perf(map): cut INP input-delay — guard rebuild loops, throttle hover query, yield-schedule heavy commit (koala73#5042) (koala73#5045)
* perf(map): add isInputPending() input-path scheduling helper (koala73#5042) U1 of the INP input-delay plan. Feature-detected (Chromium-only, graceful false elsewhere), discrete-only no-arg call. Recurring animation loops use it to skip a rebuild tick when input is queued. Claude-Session: https://claude.ai/code/session_01Gc3t8s5cdgecwXGxzdq5Xn * perf(map): cut input-path main-thread work — guard rebuild loops, throttle hover query, yield-schedule heavy commit (koala73#5042) U2: gate the trade-animation and news-pulse rebuilds on !isInputPending() so a queued interaction handler runs sooner (terminal pulse-settle render is never guarded). U3: rAF-throttle the per-mousemove queryRenderedFeatures on the canvas (the dominant input-delay surface per the 07-08 field pull); cancel the pending query on mouseout and teardown so it can't re-highlight after leave. U4: route the DeferredHeavyCommit flush through a yieldToMain-backed adapter (scheduler.yield ahead of clamped timers) instead of setTimeout(0). Claude-Session: https://claude.ai/code/session_01Gc3t8s5cdgecwXGxzdq5Xn * perf(map): surface deferred-flush throws on window.onerror (koala73#5045 review) A throw from the DeferredHeavyCommit flush was a floating promise rejection; catch it and rethrow async so it reaches window.onerror -> Sentry exactly as the replaced setTimeout(fn,0) did. (Greptile P2.) Claude-Session: https://claude.ai/code/session_01Gc3t8s5cdgecwXGxzdq5Xn * fix(map): keep throttled hover interactions coherent * fix(map): skip hover raf when country clicks are disabled
1 parent 17afd26 commit 306dd99

6 files changed

Lines changed: 378 additions & 12 deletions

File tree

src/components/DeckGLMap.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,16 @@ import { getCachedFuelShortageRegistry } from '@/shared/fuel-shortage-registry-s
7575
import { tokenizeForMatch, matchKeyword, matchesAnyKeyword, findMatchingKeywords } from '@/utils/keyword-match';
7676
import { t } from '@/services/i18n';
7777
import { debounce, rafSchedule, getCurrentTheme } from '@/utils/index';
78+
import { isInputPending, scheduleYield } from '@/utils/after-paint';
7879
import { showLayerWarning } from '@/utils/layer-warning';
7980
import { localizeMapLabels } from '@/utils/map-locale';
81+
import {
82+
createCountryHoverQueryController,
83+
resolveCountryForPointerInteraction,
84+
shouldRenderTradeAnimationFrame,
85+
shouldRunInputSensitiveMapWork,
86+
type CountryHoverQueryController,
87+
} from './map/input-delay-interactions';
8088
import { getCachedMilitaryBases, preloadMilitaryBases } from '@/services/military-base-config';
8189
import {
8290
INTEL_HOTSPOTS,
@@ -702,13 +710,18 @@ export class DeckGLMap {
702710
const y = e.clientY - rect.top;
703711
const lngLat = this.maplibreMap.unproject([x, y]);
704712
if (!Number.isFinite(lngLat.lng)) return;
713+
const country = resolveCountryForPointerInteraction(
714+
{ code: this.hoveredCountryIso2, name: this.hoveredCountryName },
715+
this.hoverQueryThrottle?.isPending() ?? false,
716+
() => this.resolveCountryFromCoordinate(lngLat.lng, lngLat.lat),
717+
);
705718
this.onMapContextMenu({
706719
lat: lngLat.lat,
707720
lon: lngLat.lng,
708721
screenX: e.clientX,
709722
screenY: e.clientY,
710-
countryCode: this.hoveredCountryIso2 ?? undefined,
711-
countryName: this.hoveredCountryName ?? undefined,
723+
countryCode: country?.code,
724+
countryName: country?.name,
712725
});
713726
};
714727
private onLayerChange?: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void;
@@ -737,7 +750,9 @@ export class DeckGLMap {
737750
* lands off the measured frame. The gate skips the defer when data is unchanged.
738751
*/
739752
private readonly heavyGate = new DeferredHeavyCommit<unknown>({
740-
schedule: (fn) => { const id = setTimeout(fn, 0); return () => clearTimeout(id); },
753+
// Yield the deferred heavy-layer flush off the interaction frame via
754+
// scheduler.yield (ahead of clamped timers, behind queued input) — #5042 U4.
755+
schedule: (fn) => scheduleYield(fn),
741756
isAlive: () => !this.renderPaused && !this.webglLost && !!this.maplibreMap,
742757
onCommit: () => this.updateLayers(true),
743758
});
@@ -4279,7 +4294,10 @@ export class DeckGLMap {
42794294
return;
42804295
}
42814296
this.pulseTime = now;
4282-
this.rafUpdateLayers();
4297+
// Steady-state pulse rebuild: skip when input is queued (#5042 U2). The
4298+
// terminal settle branch above is never guarded — it must commit the
4299+
// static end state. pulseTime still advances so no visual state is lost.
4300+
if (shouldRunInputSensitiveMapWork(isInputPending)) this.rafUpdateLayers();
42834301
}, PULSE_UPDATE_INTERVAL_MS);
42844302
}
42854303

@@ -4954,11 +4972,12 @@ export class DeckGLMap {
49544972
let country: { code: string; name: string } | null = null;
49554973
if (isChoropleth && info.object?.properties) {
49564974
country = { code: info.object.properties['ISO3166-1-Alpha-2'] as string, name: info.object.properties.name as string };
4957-
} else if (this.hoveredCountryIso2 && this.hoveredCountryName) {
4958-
// Use pre-resolved hover state for instant response
4959-
country = { code: this.hoveredCountryIso2, name: this.hoveredCountryName };
49604975
} else {
4961-
country = this.resolveCountryFromCoordinate(lon, lat);
4976+
country = resolveCountryForPointerInteraction(
4977+
{ code: this.hoveredCountryIso2, name: this.hoveredCountryName },
4978+
this.hoverQueryThrottle?.isPending() ?? false,
4979+
() => this.resolveCountryFromCoordinate(lon, lat),
4980+
);
49624981
}
49634982
// Only fire if we have a country — ocean/no-country clicks are silently ignored
49644983
if (country?.code && country?.name) {
@@ -6300,7 +6319,10 @@ export class DeckGLMap {
63006319
this.tradeAnimationTime = (this.tradeAnimationTime + delta * TRADE_ANIMATION_SPEED) % TRADE_ANIMATION_CYCLE;
63016320
this.tradeAnimationFrame = requestAnimationFrame(animate);
63026321
this.tradeAnimationFrameCount++;
6303-
if (this.tradeAnimationFrameCount % 2 === 0) this.render();
6322+
// Skip this decorative rebuild when input is queued so the pending
6323+
// interaction handler runs sooner (#5042 U2). Frame-predictive and
6324+
// discrete-only; graceful no-op where isInputPending is unsupported.
6325+
if (shouldRenderTradeAnimationFrame(this.tradeAnimationFrameCount, isInputPending)) this.render();
63046326
};
63056327
this.tradeAnimationFrame = requestAnimationFrame(animate);
63066328
}
@@ -7496,11 +7518,14 @@ export class DeckGLMap {
74967518
map.setFilter('country-hover-border', noMatch);
74977519
};
74987520

7499-
map.on('mousemove', (e) => {
7521+
// rAF-throttle the per-mousemove feature query (#5042 U3). The canvas is the
7522+
// dominant input-delay surface and queryRenderedFeatures is synchronous, so
7523+
// coalesce to at most one query per frame; the latest pointer position wins.
7524+
const runHoverQuery = (point: maplibregl.Point) => {
75007525
if (!this.onCountryClick) return;
75017526
try {
75027527
if (!map.getLayer('country-interactive')) return;
7503-
const features = map.queryRenderedFeatures(e.point, { layers: ['country-interactive'] });
7528+
const features = map.queryRenderedFeatures(point, { layers: ['country-interactive'] });
75047529
const props = features?.[0]?.properties;
75057530
const iso2 = props?.['ISO3166-1-Alpha-2'] as string | undefined;
75067531
const name = props?.['name'] as string | undefined;
@@ -7518,9 +7543,25 @@ export class DeckGLMap {
75187543
clearHover();
75197544
}
75207545
} catch { /* style not done loading during theme switch */ }
7546+
};
7547+
const hoverQueryThrottle = createCountryHoverQueryController<maplibregl.Point>(
7548+
rafSchedule,
7549+
runHoverQuery,
7550+
);
7551+
this.hoverQueryThrottle = hoverQueryThrottle;
7552+
7553+
map.on('mousemove', (e) => {
7554+
if (!this.onCountryClick) {
7555+
hoverQueryThrottle.cancel();
7556+
return;
7557+
}
7558+
hoverQueryThrottle.queue(e.point);
75217559
});
75227560

75237561
map.on('mouseout', () => {
7562+
// Cancel a queued query so a deferred rAF cannot re-highlight a country the
7563+
// pointer has already left (R8); clearHover stays immediate.
7564+
hoverQueryThrottle.cancel();
75247565
if (hoveredIso2) {
75257566
hoveredIso2 = null;
75267567
try { clearHover(); } catch { /* style not done loading */ }
@@ -7529,6 +7570,7 @@ export class DeckGLMap {
75297570
}
75307571

75317572
private countryPulseRaf: number | null = null;
7573+
private hoverQueryThrottle: CountryHoverQueryController<maplibregl.Point> | null = null;
75327574

75337575
private getHighlightRestOpacity(): { fill: number; border: number } {
75347576
const theme = isLightMapTheme(getMapTheme(getMapProvider())) ? 'light' : 'dark';
@@ -7718,6 +7760,7 @@ export class DeckGLMap {
77187760
this.debouncedFetchBases.cancel();
77197761
this.debouncedFetchAircraft.cancel();
77207762
this.rafUpdateLayers.cancel();
7763+
this.hoverQueryThrottle?.cancel();
77217764
this.heavyGate.cancel();
77227765

77237766
if (this.renderRafId !== null) {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
export interface CountryIdentity {
2+
code: string;
3+
name: string;
4+
}
5+
6+
export interface CountryHoverCache {
7+
code: string | null;
8+
name: string | null;
9+
}
10+
11+
export type CancelableFrameTask = (() => void) & { cancel(): void };
12+
13+
export interface CountryHoverQueryController<TPoint> {
14+
queue(point: TPoint): void;
15+
cancel(): void;
16+
isPending(): boolean;
17+
}
18+
19+
export function shouldRunInputSensitiveMapWork(isInputPending: () => boolean): boolean {
20+
return !isInputPending();
21+
}
22+
23+
export function shouldRenderTradeAnimationFrame(frameCount: number, isInputPending: () => boolean): boolean {
24+
return frameCount % 2 === 0 && shouldRunInputSensitiveMapWork(isInputPending);
25+
}
26+
27+
export function resolveCountryForPointerInteraction(
28+
hover: CountryHoverCache,
29+
hoverQueryPending: boolean,
30+
resolveFromCoordinate: () => CountryIdentity | null,
31+
): CountryIdentity | null {
32+
if (!hoverQueryPending && hover.code && hover.name) {
33+
return { code: hover.code, name: hover.name };
34+
}
35+
return resolveFromCoordinate();
36+
}
37+
38+
export function createCountryHoverQueryController<TPoint>(
39+
scheduleFrame: (run: () => void) => CancelableFrameTask,
40+
runQuery: (point: TPoint) => void,
41+
): CountryHoverQueryController<TPoint> {
42+
let pendingPoint: TPoint | null = null;
43+
let pending = false;
44+
const frameTask = scheduleFrame(() => {
45+
const point = pendingPoint;
46+
pendingPoint = null;
47+
pending = false;
48+
if (point !== null) runQuery(point);
49+
});
50+
51+
return {
52+
queue(point: TPoint): void {
53+
pendingPoint = point;
54+
pending = true;
55+
frameTask();
56+
},
57+
cancel(): void {
58+
pendingPoint = null;
59+
pending = false;
60+
frameTask.cancel();
61+
},
62+
isPending(): boolean {
63+
return pending;
64+
},
65+
};
66+
}

src/utils/after-paint.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,53 @@ export function yieldToMain(): Promise<void> {
5757
}
5858
return new Promise((resolve) => { setTimeout(resolve, 0); });
5959
}
60+
61+
/**
62+
* Whether the browser currently has queued input (a click/keypress) waiting to
63+
* be dispatched. Recurring, animation-driven work on the input path calls this
64+
* and skips its tick when input is pending, so the interaction handler can run
65+
* sooner (#5042).
66+
*
67+
* Feature-detected: `navigator.scheduling.isInputPending` is Chromium-only,
68+
* which matches INP being a Chromium-measured metric. Returns `false` wherever
69+
* the API is absent (Firefox/Safari/node), so callers degrade to today's
70+
* behavior with no functional change.
71+
*
72+
* Called with no arguments, so it reports DISCRETE input (clicks/keys) only —
73+
* `includeContinuous` defaults to `false`. That is the correct scope for the
74+
* click/keypress control targets; passing `{ includeContinuous: true }` would
75+
* also report pointermove/wheel/drag and stall callers during any hover/pan.
76+
*/
77+
export function isInputPending(): boolean {
78+
const scheduling = (globalThis as unknown as {
79+
navigator?: { scheduling?: { isInputPending?: () => boolean } };
80+
}).navigator?.scheduling;
81+
if (typeof scheduling?.isInputPending !== 'function') return false;
82+
return scheduling.isInputPending();
83+
}
84+
85+
/**
86+
* Adapt `yieldToMain()` to a fire-once scheduler with a cancel handle:
87+
* `scheduleYield(run)` runs `run` after the yield resolves and returns a cancel
88+
* that prevents it. `DeferredHeavyCommit` (#4558) depends on this shape to
89+
* coalesce a burst of stages into one flush and to drop the flush on teardown;
90+
* the `yieldToMain` promise can't be cleared, so an abort flag preserves those
91+
* semantics. Prefer this over `setTimeout(0)`: `scheduler.yield()` resumes ahead
92+
* of clamped timer work (but still behind queued input), lowering commit latency
93+
* without newly blocking the input path (#5042 U4).
94+
*/
95+
export function scheduleYield(run: () => void): () => void {
96+
let aborted = false;
97+
void yieldToMain().then(() => {
98+
if (aborted) return;
99+
try {
100+
run();
101+
} catch (err) {
102+
// Surface a throw from the deferred flush on the global error channel
103+
// (window.onerror -> Sentry) exactly as the replaced setTimeout(fn, 0)
104+
// did, rather than leaving it as a floating promise rejection.
105+
setTimeout(() => { throw err; });
106+
}
107+
});
108+
return () => { aborted = true; };
109+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { isInputPending } from '@/utils/after-paint';
4+
5+
type Scheduling = { isInputPending?: (...args: unknown[]) => boolean };
6+
7+
/**
8+
* Swap `globalThis.navigator` around a call. Node 22+ ships a real `navigator`
9+
* global (configurable), so save/restore its property descriptor rather than a
10+
* plain assignment — an ESM (strict-mode) assignment to a getter-only global
11+
* would throw.
12+
*/
13+
function withNavigator<T>(value: { scheduling?: Scheduling } | undefined, run: () => T): T {
14+
const desc = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
15+
if (value === undefined) {
16+
delete (globalThis as { navigator?: unknown }).navigator;
17+
} else {
18+
Object.defineProperty(globalThis, 'navigator', { value, configurable: true, writable: true });
19+
}
20+
try {
21+
return run();
22+
} finally {
23+
if (desc) Object.defineProperty(globalThis, 'navigator', desc);
24+
else delete (globalThis as { navigator?: unknown }).navigator;
25+
}
26+
}
27+
28+
test('isInputPending returns true when the API reports pending input (#5042)', () => {
29+
const val = withNavigator({ scheduling: { isInputPending: () => true } }, () => isInputPending());
30+
assert.equal(val, true);
31+
});
32+
33+
test('isInputPending returns false when the API reports no pending input', () => {
34+
const val = withNavigator({ scheduling: { isInputPending: () => false } }, () => isInputPending());
35+
assert.equal(val, false);
36+
});
37+
38+
test('isInputPending returns false when navigator.scheduling lacks isInputPending (graceful, R6)', () => {
39+
const val = withNavigator({ scheduling: {} }, () => isInputPending());
40+
assert.equal(val, false);
41+
});
42+
43+
test('isInputPending returns false when navigator.scheduling is absent (R6)', () => {
44+
const val = withNavigator({}, () => isInputPending());
45+
assert.equal(val, false);
46+
});
47+
48+
test('isInputPending returns false when navigator is absent (node/SSR, R6)', () => {
49+
const val = withNavigator(undefined, () => isInputPending());
50+
assert.equal(val, false);
51+
});
52+
53+
test('isInputPending calls the API with no arguments (discrete-only scope)', () => {
54+
let receivedArgs: unknown[] | null = null;
55+
withNavigator(
56+
{ scheduling: { isInputPending: (...args: unknown[]) => { receivedArgs = args; return false; } } },
57+
() => isInputPending(),
58+
);
59+
assert.deepEqual(receivedArgs, [], 'no-arg call keeps includeContinuous at its false default');
60+
});

tests/after-paint-yield.test.mts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test } from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { yieldToMain } from '@/utils/after-paint';
3+
import { yieldToMain, scheduleYield } from '@/utils/after-paint';
44

55
type SchedulerHost = { scheduler?: { yield?: () => Promise<void> } };
66

@@ -51,3 +51,54 @@ test('yieldToMain returns a Promise in both paths (signature preserved)', () =>
5151
assert.ok(withoutYield instanceof Promise);
5252
return Promise.all([withYield, withoutYield]);
5353
});
54+
55+
test('scheduleYield runs the callback after the yield resolves (#5042 U4)', async () => {
56+
let ran = 0;
57+
await withScheduler({ yield: () => Promise.resolve() }, async () => {
58+
scheduleYield(() => { ran += 1; });
59+
await Promise.resolve();
60+
await Promise.resolve();
61+
await Promise.resolve();
62+
});
63+
assert.equal(ran, 1, 'callback fires once after the yield');
64+
});
65+
66+
test('scheduleYield cancel before resolution prevents the callback (coalesce/teardown, U4)', async () => {
67+
let ran = 0;
68+
await withScheduler({ yield: () => Promise.resolve() }, async () => {
69+
const cancel = scheduleYield(() => { ran += 1; });
70+
cancel();
71+
await Promise.resolve();
72+
await Promise.resolve();
73+
await Promise.resolve();
74+
});
75+
assert.equal(ran, 0, 'a pre-resolution cancel drops the flush');
76+
});
77+
78+
test('scheduleYield rethrows deferred callback errors on the timer channel (#5042 U4)', async () => {
79+
const sentinel = new Error('deferred flush failed');
80+
let timerHandler: (() => void) | null = null;
81+
const originalSetTimeout = globalThis.setTimeout;
82+
globalThis.setTimeout = ((handler: Parameters<typeof globalThis.setTimeout>[0]) => {
83+
assert.equal(typeof handler, 'function', 'scheduleYield should surface errors with a function timer');
84+
timerHandler = handler as () => void;
85+
return 0 as ReturnType<typeof globalThis.setTimeout>;
86+
}) as typeof globalThis.setTimeout;
87+
88+
try {
89+
await withScheduler({ yield: () => Promise.resolve() }, async () => {
90+
scheduleYield(() => { throw sentinel; });
91+
await Promise.resolve();
92+
await Promise.resolve();
93+
await Promise.resolve();
94+
});
95+
96+
assert.ok(timerHandler, 'failed deferred flush should be rethrown via setTimeout');
97+
assert.throws(
98+
() => { timerHandler?.(); },
99+
(err) => err === sentinel,
100+
);
101+
} finally {
102+
globalThis.setTimeout = originalSetTimeout;
103+
}
104+
});

0 commit comments

Comments
 (0)