Drag a box over the map — what rectangle hit testing actually returns in Maps SDK 5.1
“Let the user drag a box and tell me what is inside it.” It is one of the
most requested interactions in any map-facing application, and until recently it took real work
— a sketch tool, a drawn geometry, a query and a pile of state to keep the three in step.
ArcGIS Maps SDK for JavaScript 5.1 shortens that considerably: hitTest()
now accepts a screen rectangle, and a new fetchPopupFeatures() method on the map,
scene and link chart components accepts one too.
That shortcut is worth having. It is also worth understanding precisely, because a rectangle hit test answers a question about the display — what is drawn under this box, right now — and most of the workflows people reach for a rectangle to build are asking a question about the data. Those two questions return the same answer on a demo extent and different answers on a client dataset. Here is what shipped, the construction Esri publishes for it, the three traps between that sample and something you would put in front of a paying user, and the one requirement none of them covers.
What actually changed at 5.1
Three related changes: two new capabilities on the view components, and one deprecation on the popup.
| Member | Status at 5.1 | What it does |
|---|---|---|
hitTest() |
Existing method, new ScreenRect target (beta, 2D) |
Returns hit results from every layer intersecting a point or a rectangle |
fetchPopupFeatures() |
New at 5.1; rectangle target beta | Returns popup-eligible features at a screen point or rectangle, as an async generator |
arcgis-popup.fetchFeatures() |
Deprecated since 5.1 | Superseded by fetchPopupFeatures() on the view component |
The signatures, from the arcgis-map reference, are worth reading closely
because the accepted target types differ:
// hitTest takes a mouse event, a point, or - new at 5.1 - a rectangle hitTest(hitTarget: MouseEvent | ScreenPoint | ScreenRect, options?: HitTestOptions): Promise<ViewHitTestResult> // fetchPopupFeatures is new at 5.1 and does NOT take a MouseEvent fetchPopupFeatures(hitTarget: ScreenPoint | ScreenRect, options?: FetchPopupFeaturesOptions): Promise<AsyncGenerator<Graphic>>
Note that hitTest() still accepts a raw MouseEvent
and fetchPopupFeatures() does not. If you are migrating a click handler
from the deprecated popup method, that is the first line you will have to change.
fetchPopupFeatures() is unambiguous: “Using ScreenRect as the hit target
is considered beta functionality.” The release-specific changes list on
hitTest() says the same thing: “At version 5.1, beta support was added
for ScreenRect, allowing hit tests across rectangular screen areas.” Point targets are stable;
the rectangle is not, and it is documented for 2D. Keep the beta surface behind one function so the
blast radius of a 6.0 change is a single file, and pin your SDK version.The construction Esri publishes
The 5.1 sample Hit test features by screen rectangle puts an SVG rectangle inside the
arcgis-map element and passes its client rectangle straight to
hitTest(). Stripped to the part that matters:
// Runs area-based hit testing using the rectangle overlay. async function runRectHitTest() { const rect = document.getElementById("rect").getBoundingClientRect(); // The rectangle is the hit target. include scopes the test to one layer. const hit = await viewElement.hitTest(rect, { include: [layer] }); const graphics = hit.results .filter((result) => result.graphic) .map((result) => result.graphic); hitHighlight?.remove(); if (graphics.length > 0) { hitHighlight = layerView.highlight(graphics); } }
Three things in that snippet are easy to skim past.
First, a browser DOMRect satisfies the
ScreenRect target. ScreenRect is documented as
x, y, width and
height, and a DOMRect supplies all four, so it
fits structurally — you do not need to construct anything special.
Second, and this is the part to copy deliberately rather than by accident: the sample gets away with
passing that rectangle straight through only because of how its page is laid out.
getBoundingClientRect() always returns viewport coordinates, while a
ScreenPoint or ScreenRect is measured from the
view’s own top-left corner. In the sample the body has no margin, there is no header, and the only
panel is docked to the right, so the map element sits at viewport (0, 0) and the two coordinate spaces
happen to coincide. Nesting the box inside arcgis-map does not change this
— parentage has no effect on getBoundingClientRect().
In any application where the map is not at the viewport origin — below a navbar, inside a
padded panel — subtract the view element’s own bounding rectangle first. That correction
applies to the hitTest rectangle just as much as to anything else you
convert. A selection box silently shifted by the height of your navbar is a maddening bug to chase.
Third, the include option is carrying its weight. Without it the test runs
against every layer in the map, which on a busy web map is both slower and noisier than you want.
arcgisViewChange whenever
the view becomes stationary — area inspection, not drag-to-select. Esri names drag-to-select as a
workflow the rectangle target enables; shipping the drag itself, along with its pointer
handlers and its keyboard equivalent, is still yours to write.fetchPopupFeatures returns a generator, not an array
The new method hands back Promise<AsyncGenerator<Graphic>>,
which lets features arrive as they resolve rather than all at the end. The documented pattern is a
for await loop:
const viewElement = document.querySelector("arcgis-map"); viewElement.addEventListener("arcgisViewClick", async (event) => { const generator = await viewElement.fetchPopupFeatures( event.detail.screenPoint, { pointerType: event.detail.pointerType } ); // features stream in as they resolve for await (const feature of generator) { console.log(feature); } });
When you need the whole set before doing anything — a count, a sort, an export — the
reference shows collecting it with Array.fromAsync() instead. Reach for that
only when the work is inherently whole-set; collapsing the generator immediately discards the one
benefit it was added for.
One limit to know: fetchPopupFeatures() returns features from origins
— layers and sublayers — that are configured with a PopupTemplate
and have popupEnabled set. A layer with no popup template is invisible to it.
That is correct behavior for a popup-driven side panel and quietly wrong for a general selection tool,
which brings us to the real point.
The job this is right for, and the job it is not
Take a concrete workflow. A utility gives you a hosted feature layer of roughly four hundred thousand service points and asks for a review tool: the inspector drags a box over a block, and the panel lists every service point inside it with its last inspection date.
The rectangle hit test looks like a perfect fit, and on a single block at close zoom it behaves like one. Then the inspector zooms out one level, drags a box over a neighborhood, and the panel reports a number that is confidently, silently too low.
The reason is not a bug. A hit test is a display operation: it reports what intersects the target among the features the view has drawn. A feature layer of that size streams tiles of features for the current extent and does not hold all of them on the client. Add a display filter — a common way to keep a heavy layer responsive — and the gap widens further.
Trap one: three siblings with nearly the same name
The SDK gives you the checks you need. It gives you three of them, on
FeatureLayerView, with names a tired reader will conflate:
| Property | What true means |
|---|---|
hasAllFeatures |
The layer view “contains all available features from the service or source.” Queries on the layer view run against the entire dataset. |
hasAllFeaturesInView |
The layer view “has successfully retrieved all relevant data for the current extent.” Queries on the layer view are accurate for what is in view. |
hasFullGeometries |
Layer view geometries are full resolution rather than quantized to the view scale. |
They divide the work cleanly once you see it. hasAllFeaturesInView tells
you whether the display-side answer was even complete for the current extent — which is to
say, whether the hit test you just ran could have been right. A false value means, in the docs’ own
words, that “you may need to query the layer and its service directly to get accurate results”;
the causes they name include a large layer configured with a display filter. The other two gate the
data-side answer, and the notes on queryFeatureCount() name both:
hasAllFeatures for whether the client holds the whole dataset, and
hasFullGeometries for whether its geometries are full resolution rather than
quantized to the view scale. That second one is the quiet one — quantized edge geometry can flip
whether a parcel straddling your box counts as inside it.
So the disciplined version does not ask the display what is in the box. It builds a real map geometry, then asks whichever object can answer authoritatively. Both halves have a sharp edge.
Build the geometry from four corners, not two. An axis-aligned extent derived from
one diagonal is only correct while rotation is zero, and the SDK lets a user
rotate the view with right-click and drag. Convert all four corners and keep the polygon:
const [Polygon] = await $arcgis.import(["@arcgis/core/geometry/Polygon.js"]); const viewBox = viewElement.getBoundingClientRect(); const box = boxElement.getBoundingClientRect(); // getBoundingClientRect() is viewport space; toMap() wants view-container space. const toView = (x, y) => ({ x: x - viewBox.left, y: y - viewBox.top }); // Four corners, because a rotated view turns the box into a rotated // quadrilateral - an extent from one diagonal covers the wrong ground. const pts = [ toView(box.left, box.top), toView(box.right, box.top), toView(box.right, box.bottom), toView(box.left, box.bottom) ].map((p) => viewElement.toMap(p)); const area = new Polygon({ rings: [[...pts.map((p) => [p.x, p.y]), [pts[0].x, pts[0].y]]], spatialReference: pts[0].spatialReference });
Ask for a count, not for an array you then measure. This is the one that bites
hardest, because it fails in exactly the way this article is about.
queryFeatures() against a service returns at most that service’s
maxRecordCount features — commonly 2,000 on a hosted feature service.
Read result.features.length and a box containing forty thousand points
reports 2,000, with no error and no warning. queryFeatureCount() asks for a
number instead of a page of records, so the service cap does not truncate the answer:
const [reactiveUtils] = await $arcgis.import(["@arcgis/core/core/reactiveUtils.js"]); // whenOnce, NOT when: when() fires on a false -> true transition and never on // true -> true, so on an already-settled view a when() callback never runs. await reactiveUtils.whenOnce(() => !layerView.dataUpdating); // The queryFeatureCount notes name both gates for a spatial count: // hasAllFeatures for completeness, hasFullGeometries for edge precision. const local = layerView.hasAllFeatures && layerView.hasFullGeometries; const source = local ? layerView : layer; // On the layer view branch createQuery() folds in that view's own filter; // on the layer branch it carries the definitionExpression. A bare // { geometry } object gets neither. const query = source.createQuery(); query.geometry = area; panel.setCount(await source.queryFeatureCount(query));
Three details in that block are load-bearing.
reactiveUtils.when() only fires when its expression changes into a
truthy value. The reference is explicit: these functions “only trigger the callback when the
expression changes and then the value satisfies the expression, such as false -> true -> false,
but not true -> true.” A user drawing a box on an already-settled map is that second case, so
a when() callback would never run and the panel would simply never update.
whenOnce() returns a promise that resolves once the condition holds, and
Esri’s own samples await it in exactly this position. Second, the
branch reads two properties, not one, because the queryFeatureCount() notes
name both. Third, building the query with createQuery() rather than a bare
object is what makes the branch honest — see below.
If the panel also needs rows, fetch them as a second, paged query, and put a guard in the UI for the case where the count is larger than a list can usefully show.
createQuery(), whose documentation notes that “parameters of the filter
currently applied to the layer view are also incorporated in the returned query object.” Build the
query by hand and the filter is bypassed on both branches; build it from the layer view and it applies
there but has no equivalent on the service, which knows only
definitionExpression. Either way, a filter used to hide rows a given user
should not see belongs in definitionExpression or in server-side access
control, never in a layer view filter. And the fallback branch is a server round-trip on every box: no
credits on an ArcGIS Online organization, a billed service transaction on ArcGIS Location Platform, and
load you have to size for on Enterprise. Debounce it, and know which account the client is on before you
promise a live count.Esri publishes a second sample, Select features by rectangle, that sidesteps the geometry
problem entirely: the user draws the rectangle with SketchViewModel, so it is
map geometry from the start and rotation never enters into it. If you are starting from scratch, that is
the more robust construction. Note that the sample queries the layer view unconditionally, without the
branch above — fine for its demo layer, and exactly what stops being fine at scale.
Trap two: imagery turns your rectangle back into a point
One row of the 2D hit test behavior table in the arcgis-map reference is
the most useful line in this whole release. For ImageryLayer and
ImageryTileLayer, hit testing returns a
RasterHit — support added at 5.1 — and then: “The
screenRect hitTarget returns hit result at the center of the rectangle.”
In other words, a rectangle hit test over an imagery layer is not sampling the rectangle. It is sampling one point in the middle of it. That is a defensible design decision, and it is not what “area-based hit testing” leads you to expect. Price it too, if the imagery is yours to publish: hosted imagery is a separate entitlement from a standard feature layer, and it consumes storage credits.
The wider consequence costs nothing to guard against and is unpleasant to debug. Hit results are a
union of types, and 5.1 widened it. RasterHit is the member a 2D map will
actually produce; LayerHit joined the same union at 5.1 but is documented as
coming from SceneView.hitTest() when a Gaussian splat layer is hit. Neither
carries a graphic property. Code written before 5.1 that reached for the
first result and assumed a graphic will now sometimes find something else:
// Fine until an imagery layer is added to the map const graphic = response.results[0].graphic; // may be undefined at 5.1 // Check the discriminant instead const result = response.results[0]; if (result?.type === "graphic") { console.log("hit", result.graphic); }
The sample’s filter((result) => result.graphic) is already safe
against this, because the new members have no such property. It is the bare
results[0].graphic in older code that breaks. Nothing in your build will warn
you, and the layer that triggers the failure may be added to the web map months after your code shipped,
by someone who has never seen it.
Trap three: the deprecation clock is already running
The popup rework at 5.1 moves more than one member, and the replacements do not live in the same
place. On arcgis-popup, fetchFeatures() and the
promises property are both deprecated as of 5.1, and the reference points
you at “the fetchPopupFeatures method on the Map, Scene, or Link Chart component.” The same
deprecation lands on arcgis-features, which is easy to miss if you only read
the popup page.
One member did not get a deprecation period. arcgis-popup’s
triggerAction() method is listed in the 5.1 changelog as
removed — the single breaking change in the rework. The
arcgisTriggerAction event still exists; the method is gone.
That sits inside the larger migration Esri set out at 5.0: all widgets are deprecated, components are
the path forward, and the release notes state that widgets “will begin to be removed in Q1 2027
(version 6.0).” If you maintain an application still built on MapView
plus widgets, that calendar decides whether wiring this in is worth doing at all before the surrounding
chrome has to be rebuilt anyway. Deprecation is not removal, but there is now a version number attached
to the removal.
One thing the rectangle cannot do on its own
Drag-to-select is a pointer gesture, and a pointer gesture alone is not an accessible control. Keyboard and switch users need a second route to the same result — a “select what is in view” action, or a form that takes an extent, driving the same query path as the box. This costs one small component and it is far cheaper to design in now than to retrofit after an accessibility audit. If the tool is going into a public-sector deliverable, treat it as a requirement rather than a refinement.
How to choose, in one table
The decision is not which API is better. It is which question you are asking.
| You want to know… | Use | Because |
|---|---|---|
| What is drawn under the cursor or the box, right now | hitTest() |
Display-level, in step with what the user can see, and it returns non-feature hits too |
| Which popup-eligible features are at this point or rectangle | fetchPopupFeatures() |
Streams results, respects popup configuration, replaces the deprecated popup method |
| How many features are actually in this area | queryFeatureCount(), branched on hasAllFeatures and hasFullGeometries |
Data-level, uncapped by maxRecordCount, and still correct when the layer outgrows the client |
Used for hover, inspection, drag-to-highlight and popup panels, the 5.1 rectangle target removes a genuinely annoying amount of code, and the official sample is a sound starting point. Used as a selection engine for counting, reporting or anything a person signs, it is the wrong instrument — not because it is inaccurate, but because it is accurately answering a different question.
References
- arcgis-map component reference — hitTest and fetchPopupFeatures (signatures, the 2D hit test behavior table and the release-specific changes list)
- Sample — Hit test features by screen rectangle (the official 5.1 construction, using a fixed rectangle re-tested as the view settles)
- arcgis-popup component reference (fetchFeatures and promises deprecated at 5.1; triggerAction removed)
- FeatureLayerView reference (hasAllFeatures, hasAllFeaturesInView, hasFullGeometries, dataUpdating and the query methods)
- Core types reference (the ScreenPoint and ScreenRect shapes)
- Sample — Select features by rectangle (the sketch-geometry alternative, which is immune to view rotation)
- Release notes for 5.1 (June 2026) (the beta designation, the new RasterHit member and the 6.0 removal schedule)
- Licensing and attribution (which account types meter service transactions, and the attribution requirement)
Selection that stays correct when the data grows
We build ArcGIS applications where the selection tool still returns the right answer on a four hundred thousand feature layer, not just on the demo extent. If you are designing an inspection, review or reporting workflow on the Esri stack, we can help you get the architecture right the first time.
Book a free intro call