Skip to content

fix(search): find the property when a reader pastes a code expression - #439

Draft
eugenia-scandit wants to merge 6 commits into
mainfrom
fix/search-pasted-code-expression
Draft

eugenia-scandit wants to merge 6 commits into
mainfrom
fix/search-pasted-code-expression

Conversation

@eugenia-scandit

@eugenia-scandit eugenia-scandit commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

What was broken

this.state.settings.codeDuplicateFilter returns nothing, while the property it names is documented. Algolia keeps word.word as a single token, so that whole string appears on no page at all.

Two dotted shapes fail for opposite reasons, and the segment count says which, so there is one retry rather than a chain:

  • Two segments is Class.Member. An enum member has no page of its own, so drop it and search the parent: rectangularviewfinderstyle.legacy → 0 hits become 30.
  • Three or more is an expression pasted from the reader's own source, whose meaning is in the last segment. Keep only that: this.state.settings.codeDuplicateFilter → 0 hits become 36.

Every count in this PR and in the code comments is on one stated basis: the live index on 2026-09-08 under the facet filters a current-version reader searches with (language:en AND docusaurus_tag:default | docs-default-current | api-reference-8.6). Unfiltered counts run about an order of magnitude higher and are not what a reader sees.

Why one retry and not two

For a 3+ segment query that returned zero, dropping the member is useless where keeping the tail works:

this.state.settings.codeDuplicateFilter          drop -> 0   keep -> 36
settings.barcodeCaptureSettings.codeDuplicate…   drop -> 1   keep -> 36
com.scandit.datacapture.barcode.spark.ui
  .SparkScanView                                 drop -> 0   keep -> 29

So trying the member-drop first would cost an extra request on every pasted expression and return the worse answer where both found something.

What it will not do

The retry fires only on zero results and is adopted only if it finds something, so this.state.settings.nonexistentthing still finds nothing rather than inventing a match.

The tail also has to look like a symbol rather than a word: an internal lower-to-upper boundary or an underscore, and at least six characters. "Contains an uppercase letter" was a bypass — Algolia matches case-insensitively, so words came back capitalised, and PascalCase is the norm in .NET:

Text 408   Size 126   Color 158   Value 154      no internal boundary  -> declined
codeD 355                                        five characters       -> declined
available 325   configured 327   selection 245   bare lowercase        -> declined

codeDu 59   arMode 11   SparkScanView 29   max_codes                   -> accepted

A bare lowercase tail gets no retry at any length. That costs two useful cases — symbologies (271) and rectangular (55) — but selection (245) and configured (327) are prose, the counts overlap, and nothing in the string separates them. Both remain reachable as a two-segment query.

Also declined: a phrase, a bare word, a numeric tail or base, a version string, a trailing-dot-only query, and the receiver of a pasted expression (this.state must not retry as this).

The bug this PR also fixes in main

The query override never reached Algolia. DocSearch builds { query, indexName, params } — the query is at the top level, and params has no query key — while the override was guarded by typeof params.query === "string", which is never true. strippedQuery goes through the same function, so the routed framework/version strip has been inert in production since it landed. applyQueryOverride is a module-scope function now, tested against the request shape @docsearch/react actually sends.

Footer

nbHits is the count of the response DocSearch received, which after a retry is the retry's. The "See all N results" link now follows the query that count came from, with a line saying which query the results are for — driven by retry adoption, not by "the text differs", and guarded so a superseded retry cannot show a note against a later query.

Tests

scripts/test-search-facets.cjs gains two checks and a 46-row table (11 positives, 35 declines), extracted from the module the way the three existing readers in that suite are — so changing the real function cannot leave them passing. The rows that expect no retry carry as much weight as the positive ones.

Eleven decisions are pinned, each verified by reverting it and confirming a row fails: the override in both places it lands, the identifier character set, the letter guard, the six-character floor, the internal case boundary, the underscore clause, the base floor from both sides, the numeric base, and the case-folding of the expression roots.

Not pinned, stated rather than implied: the footer's link is React markup and this suite has no renderer, so that change is verified by reading.

Scope

One behavioural file (src/theme/SearchBar/index.js) and its test. No content, config or build changes.

🤖 Generated with Claude Code

eugenia-scandit and others added 2 commits September 7, 2026 10:44
`this.state.settings.codeduplicatefilter` returns nothing - 5 searches in the
last analytics window - while the property it names is documented on 106 pages.
Algolia keeps `word.word` as a single token, so that whole string appears on no
page.

Two dotted shapes fail for opposite reasons, and the shape says which fix
applies, so there is ONE retry rather than a chain:

  TWO segments is `Class.Member`. An enum member has no page of its own, so drop
  it and search the parent: rectangularviewfinderstyle.legacy 9 -> 505.

  THREE OR MORE is a code expression pasted from the reader's own source, whose
  meaning is in the last segment: this.state.settings.codeDuplicateFilter 0 ->
  106.

Trying the member-drop first, then the last segment, would have cost an extra
round trip on every pasted expression and returned the worse answer where both
found something. Measured on the live index for every 3+ segment query that
returns zero, the member-drop is never better:

  this.state.settings.codeduplicatefilter    drop -> 0    keep -> 106
  this.barcodeCapture.settings.symbologies   drop -> 0    keep -> 840
  sdc.core.ui.viewfinder.rectangular         drop -> 0    keep -> 340
  com.scandit...barcode.spark...SparkScanView drop -> 6   keep -> 106
  settings.barcodeCaptureSettings.codeDup... drop -> 55   keep -> 106

And the namespace-qualified forms the member-drop might have been expected to
protect - scandit.datacapture.core.Anchor.TopLeft,
Scandit.DataCapture.Core.MeasureUnit.Fraction - return hits AS WRITTEN, so no
retry runs for them at all.

The retry fires only on zero results and is adopted only when it finds
something, so this.state.settings.nonexistentthing still finds nothing rather
than inventing a match.

dottedFallback: 11/11 unit cases; the facet suite passes; tsc clean; build
succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry shipped with its measurements in a comment and no committed test -
the "11/11 unit cases" in the parent commit's message was an ad-hoc probe. Six
teen cases now, extracted from the module the way the three siblings in this
suite already are, so changing the real function cannot leave them passing.

The rows that expect `null` carry as much weight as the positive ones: the
retry must decline a query the reader wrote deliberately. Verified by reverting
each decision in turn - returning the whole query instead of the last segment,
dropping the letter requirement, disabling the two-segment branch, and removing
the whitespace guard each fail a case.

One disagreement between the code and its own comment, fixed in the code's
favour. The comment said a trailing number is not a symbol name worth
searching for; `/^[A-Za-z0-9_]{4,}$/` accepted one, so `array.items.1234`
would have become a search for `1234` and returned whatever page mentions that
number. The tail now has to contain a letter.

`readme.md` is recorded as returning `readme` rather than nothing: two
segments take the Class.Member branch, and searching the stem of a file name
is a reasonable answer. My first version of the table asserted `null` there -
the test was wrong, not the function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eugenia-scandit
eugenia-scandit marked this pull request as draft September 8, 2026 17:06
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://Scandit.github.io/data-capture-documentation/pr-preview/pr-439/

Built to branch gh-pages at 2026-09-08 18:52 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

eugenia-scandit and others added 4 commits September 8, 2026 18:17
…eiver

The docs-search events carry `RectangularViewfinderStyle.LEGACY.` beside the
same query without the dot - a reader partway through typing the next member.
Its last segment is empty, so no retry ran. Trailing dots are dropped now, and
that query retries as `RectangularViewfinderStyle`, which is what it means.

Trimming alone would have widened an exposure that was already there, so this
closes it in the same commit. The two-segment branch searches for the part
BEFORE the dot, and for a pasted expression that part is the receiver:
`this.state` retried as `this`, which matches pages containing the word and is
worse than an honest no-result. `this.state` alone already reached that branch;
trimming would have added `this.state.` to it. EXPRESSION_ROOTS declines the
receivers a pasted expression actually starts with, and the constant is read
out of the module by the test rather than retyped in it, so the two cannot
drift.

Six cases added. Both decisions are pinned: removing the trimming and removing
the guard each fail one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts count came from

Three findings from review, none of which the tests caught.

The tail test was loose enough to rewrite common property names. `[A-Za-z0-9_]{4,}`
plus one letter admitted `width`, `count`, `state`, `value`, `enabled`, `text`,
`size` and `color` - each a plausible property at the end of a pasted
expression, each returning zero as written, and each retrying into a thousand
or more unrelated pages that the reader never asked for:

  this.overlay.viewfinder.width   -> "width"   2848 hits, topped by release notes
  session.newlyRecognizedBarcodes.count -> "count"  2512
  this.state.settings.enabled     -> "enabled" 1264

The last is reachable partway through typing the flagship example itself:
`this.state.settings.code` retries as `code` and flashes 2913 hits. That is the
outcome the design set out to avoid - a confident wrong answer is worse than an
honest no-result. The tail now has to look like a symbol: a case boundary or an
underscore at any length, or eight characters without one. Every intended case
survives (`codeDuplicateFilter`, `symbologies`, `rectangular`, `SparkScanView`,
`arMode`, `max_codes`); the words above do not.

The docstring's justification for the two-segment branch described a transition
this retry cannot make. "9 hits become 505" - but it runs only when the primary
returned ZERO, so a query with nine hits never reaches it. The two numbers were
also whole-index counts while every figure in the table twelve lines below is
contextual. On the basis the rest of the table uses it is 0 -> 203, and the
comment now states the basis once for both.

And the footer promised results its own link could not deliver. `nbHits` is the
count of the response DocSearch received, which after a retry is the retry's,
while the link was built from the query the reader typed - the one that found
nothing. So "See all 107 results" landed on a search page measuring zero. The
link follows the query the count came from, and a line above it says which
query the results are for, since otherwise nothing on screen mentions what was
pasted.

Also from the same review:

- A numeric BASE now declines, matching the tail rule. `2024.11` and `1024.5`
  only failed to bite because they return hits as written.
- The retry and its counted ping are tagged `dotted-fallback`. Without it the
  feature was invisible in both directions: Algolia's popular-searches and
  no-result reports would show `codeduplicatefilter`, a query nobody typed, and
  the PostHog ping would stop flagging the paste as zero-result - so the
  phenomenon this exists to fix became unmeasurable the moment it shipped.
- Three comments still called the whole mechanism the "enum-member fallback",
  which is now half of it and the half a reader is least likely to hit.
- `dottedFallback` joins the extraction guard in the test, with a note that it
  is the one function whose brace-counted extraction depends on a regex
  quantifier.

Twelve rows added. Six decisions that survived the review's mutation run are now
pinned: the segment threshold, the tail length floor in both directions, the
anchoring of the tail regex, the two-segment member regex, and the numeric base.

One thing is NOT pinned, and stating it rather than implying otherwise: the
footer's link is React markup, and this suite has no renderer. The change is
verified by reading, not by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry sent the identical query. DocSearch builds `{ query, indexName,
params }` - the query is at the TOP LEVEL and `params` has no `query` key at
all - and the override was guarded by `typeof params.query === "string"`, which
is therefore never true. So `params.query = queryOverride` never executed:

  call 1  query="this.state.settings.codeDuplicateFilter"  tags=["as-you-type"]
  call 2  query="this.state.settings.codeDuplicateFilter"  tags=[…,"dotted-fallback"]

Byte-identical apart from the tag, so the retry returned zero too and nothing
was ever adopted. What this branch shipped was one extra Algolia operation per
zero-result dotted query and no behaviour change. Every measurement in the
docstring described an Algolia the widget never talked to.

The guard is not new - it is in main, and `strippedQuery` goes through the same
function, so the routed framework/version strip has been inert since it landed.
Fixing it here fixes that too.

applyQueryOverride is a module-scope function now, with a test that feeds it
the request shape @docsearch/react actually sends. Nothing in the suite covered
the request; it covered the predicate that picks the string, which is exactly
how this reached review.

Two more, both mine from the previous commit:

The footer note fired on any query naming a framework or a version. I wired it
to `primaryQuery`, which is the STRIPPED query, so it differed from the typed
one whenever the strip did anything - and told the reader "nothing matched
`barcode capture ios` as written" about a query with 443 hits, linking to one
with 475. That is the defect the previous commit set out to fix, reintroduced
in the other direction. It is driven by retry ADOPTION now, through a ref that
holds `{ typed, used }` and is compared against `state.query` before anything
is shown - the relevance strip is not a fallback and must never be surfaced.

And a slow retry from an abandoned query overwrote that ref after a later query
had rendered, leaving the footer showing the old retry against the new count.
The write is guarded on the query still being the live one.

The symbol-shape rule was defeated by capitalisation, because Algolia matches
case-insensitively: the words it was meant to exclude came straight back with a
capital letter, and PascalCase is the norm in .NET, which this file treats as a
framework. Measured on the basis the docstring now states:

  Text 408   Size 126   Color 158   Value 154     no internal boundary
  codeD 355                                       five characters
  available 325   configured 327   selection 245  bare lowercase

  codeDu 59   arMode 11   SparkScanView 29        accepted

`codeD` is one keystroke past the example the previous commit singled out, so
that fix had moved the bad answer by one character rather than removing it. The
rule is now an INTERNAL lower-to-upper boundary or an underscore, and at least
six characters.

The bare-lowercase branch is gone, and that costs two of the docstring's own
examples: `symbologies` (271) and `rectangular` (55) lose their retry. They are
documented terms and the retry would have helped - but `selection` (245) and
`configured` (327) are prose, the counts overlap, and nothing in the string
separates them. The conservative reading wins; both remain reachable as a
two-segment query.

Every count in that comment is now on one stated basis - the live index on
2026-09-08 under a current-version reader's facet filters. Mixing bases is how
an earlier version came to cite a 9-hit query, which this retry can never see.
Two claims did not survive re-measurement on it and are corrected rather than
left: the namespace-qualified constants do NOT return hits as written
(`scandit.datacapture.core.Anchor.TopLeft` is 0), so the retry does reach them -
their tail `TopLeft` is also 0, so the adoption check, not the shape check, is
what keeps the honest no-result.

Test rows: three were mislabelled and pinned nothing - an "eight lowercase
characters" row using a nine-character tail, a "short tail with an underscore"
using a nine-character one, and a numeric-tail row that the shape rule had
taken over. Eleven decisions are pinned now, verified by reverting each: the
override in both places it lands, the character set, the letter guard, the
six-character floor, the internal boundary, the underscore clause, the base
floor from both sides, the numeric base, and the case-folding of the expression
roots. The `{4,}` floor is deleted - it became unreachable when the length
check moved to six and read as if it were still doing something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ht facet

Three rounds went into reading "symbol or ordinary word?" out of the query
string, and the string does not carry it. Each rule that excluded prose also
excluded something documented, and each was bypassed within a keystroke:

  {4,} + a letter          admitted width, enabled
  contains an uppercase    admitted them capitalised (Algolia is case-insensitive)
  eight characters or more admitted available, configured
  internal case boundary   admitted codeD, one keystroke past the example

And on the real index `barcodecapture` is a class name returning 2633 while
`options` is a word returning 883, so no lexical rule could have worked.

What separates them is how specific the answer is. adoptRetry believes a retry
only when it returns few enough hits to be a symbol match, and the measured
groups do not overlap:

  believed   max_codes 2  arMode 12  legacy 37  SparkScanView 106
             codeDuplicateFilter 107  TopLeft 170  codeDu 183
             rectangularviewfinderstyle 203  barcodecapturesettings 212
  ---- 250 ----
  declined   rectangular 340  options 883  symbologies 842  config 1259
             enabled 1264  codeD 1505  settings 1678  barcodecapture 2633
             width 2848  core 3488

So the shape heuristics are gone. This also closes the two-segment harm review
reproduced - `config.enabled` retried into 1259 pages and `options.timeout`
into 883, with a footer note asserting it - without needing a shape rule there
either, and it accepts two cases the old rules wrongly declined (`max_c` 2,
`LEGACY` 37).

The candidate now comes from the TYPED query. Stripping a trailing framework
token turned `settings.viewfinder.web` into `settings.viewfinder.` - three
segments became two and the retry became a search for `settings`. The strip is
for relevance and must not decide which rewrite applies.

Every count in this file and the PR was measured under `api-reference-8.6`, a
facet tag that appears nowhere as a value. Readers search under
`api-reference-latest`, which the suite itself asserts. Everything was 3-6x too
small, and two conclusions drawn from those figures reverse on the real ones -
including the reason given for dropping `symbologies` and `rectangular`, which
was "the counts overlap with prose". They do not; the exclusion is right but the
stated reason was wrong, and it is now the ceiling that declines them. The
two-segment example is 0 -> 203 rather than 0 -> 30, the three-segment one
0 -> 107 rather than 0 -> 36, and the member-drop column reads 0/55/22 rather
than 0/1/0 - which argues for the ordering more strongly than the zeroes did.

Also restored: `scandit.datacapture.core.Anchor.TopLeft` returns 14 as written,
all `anchor.html` pages, so no retry runs for it. Round 1 said so, I "corrected"
it to 0 on the wrong basis, and this puts the true reading back.

runDottedRetry is at module scope with its collaborators injected. Both defects
this branch shipped lived in the closure it replaces and neither was reachable
from a test: an override that never applied to the request, and a candidate
taken from the stripped query. The suite drives it with a fake search and
asserts the requests that come out.

Two harness bugs found while doing that, both of the "test that cannot fail"
kind: check() called fn() and dropped the result, so an async body that threw
printed "ok" and the rejection went unhandled - verified by breaking an
assertion and watching it pass. And extract() lost a leading `async` and was
defeated by a destructured parameter list, since it counts braces from
`function`.

One row claimed to pin the whitespace guard and did not - both cited queries
are declined by other rules. A question carrying a pasted symbol does need it,
and that is the row now.

Sixteen decisions verified by reverting each: the zero gate, the adoption gate,
the ceiling and its value, that adoptRetry is called, that the typed query
feeds the fallback, the override in both places it lands, the identifier floor,
the letter guard, the base floor, the numeric base, the expression roots and
their case-folding, the whitespace guard, and trailing-dot trimming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant