← Back to Blog

Drag a box over the map — what rectangle hit testing actually returns in Maps SDK 5.1

A rectangle dragged across a map view, separating the features currently drawn beneath it from the much larger dataset still sitting in the service behind it

“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.

MemberStatus at 5.1What 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.

Beta, and labeled as such. Esri’s wording on 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.

The sample does not actually drag. Its rectangle is fixed in the center of the screen, and the hit test re-runs on 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.

If you are not the one writing the code: the failure mode here is not a crash or an error message. It is a selection tool that returns a plausible number which happens to be wrong, in a workflow where somebody signs off on that number. The failure typically surfaces during user acceptance testing on the full dataset — the first time anyone runs the tool against more data than the developer had. Two things to insist on: a test dataset at production scale, and an answer to one question — what does this return when the box contains more features than the service will hand back in a single request?

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:

PropertyWhat 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.

The two branches are not interchangeable, and the fallback is not free. A layer view filter is a client-side construct, and it reaches a query only through 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…UseBecause
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.

The one-line version. A hit test tells you what the map is showing. A query tells you what the data holds. Build the interaction with the first and the answer with the second — and make sure the answer is a count, not the length of a capped array. For the interaction, 5.1 really has removed most of the work. For the answer, you are still writing a geometry, a query and a little state; what changed is that you now know which of the two you are building.

References

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