You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Hoist a shared query-value encoder — six call sites still use raw .urlQueryAllowed for user-typed values #95
CharacterSet.urlQueryAllowedpermits+, &, =, ?, # and /. Percent-encoding a query
value with it therefore leaves those characters intact, and:
a raw + decodes to a space on the server, and
a raw & or =splits the value into another query parameter.
For a search box, that means a term like a & b or c++ silently searches for something else, or
truncates. For an opaque cursor it means paging quietly breaks — the page repeats or ends early,
with no error anywhere.
Call sites still using the raw set
All passing user-typed or server-issued opaque values (Services/APIClient.swift, approx. lines):
~Line
What it encodes
640
search term
709
search term
1097
search term
1209
search term
1325
search term
1467
pagination cursor
The cursor one is the most likely to be actively broken today, since base64 routinely contains +
and =.
Three copies of the fix already exist
Each was written locally because the branches were independent:
Three occurrences is past the point where it should be one function.
Build this
Hoist a single queryValue(_:) into APIClientTransport.swift alongside the other transport
helpers (internal, not private — private is file-scoped and the per-feature extension
files could not see it).
Allowed set: .urlQueryAllowed minus CharacterSet(charactersIn: "+&=?#/").
Replace all three local copies with it.
Convert the six raw .urlQueryAllowed call sites above.
Tests: a search term containing +, &, = and a space round-trips unchanged through URLComponents; a base64 cursor containing +, / and = round-trips unchanged.
Sequencing
Touches APIClient.swift and both extension files, so it will conflict with anything else in flight
there. Best run after#86 and #93 merge, so all three copies exist in one tree and can be
collapsed in a single pass.
Acceptance criteria
Exactly one query-value encoder in the codebase.
No remaining addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) on a query value (path encoding via .urlPathAllowed is a different thing and stays).
Suite green; no behaviour change other than correct encoding.
The test this issue specified would not have caught the main hazard
This issue asked for "a search term containing +, &, = and a space round-trips unchanged
through URLComponents". That assertion passes against the broken encoder, which I verified
directly:
URLComponents preserves a literal + when decoding, so only & and = make the round-trip fail.
But + is the one actively breaking base64 cursors, because the backend reads params through URLSearchParams, which decodes + as a space.
#111 asserts on the wire form instead (XCTAssertFalse(rawQuery.contains("+"))), which is the
check that actually bites. Worth remembering for the next encoding test: assert what goes on the
wire, not what a client-side parser hands back.
Outcome
Exactly the three copies this issue predicted, and exactly six raw call sites — five search terms
plus the dmThreadUpdatesafter cursor. Line numbers had drifted; the set had not.
Three already-correct sites were repointed at the shared helper for free.
One shared queryValue(_:) now lives in APIClientTransport.swift beside pathSegment(_:), with
a comment saying why they are not interchangeable.
The old orgQueryValueAllowed stripped &=+?# but not /; the shared set also strips /. Org
search values now go over the wire with %2F rather than a bare slash — strictly more encoding,
decoding identically server-side. excludeMembers commas are untouched, since , is not in the
strip set, and the test pinning "the backend splits this list on a literal comma" still passes.
Noted, not fixed
About a dozen path segments are still encoded inline as id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id even though pathSegment(_:) exists for exactly that. Correct as written, just not using the helper — same
rule-of-three argument, but a separate sweep. Filed as #112.
Activity
Adron commentedon Sep 17, 2026
Implemented in #111, now merged. Closing.
The test this issue specified would not have caught the main hazard
This issue asked for "a search term containing
+,&,=and a space round-trips unchangedthrough
URLComponents". That assertion passes against the broken encoder, which I verifieddirectly:
URLComponentspreserves a literal+when decoding, so only&and=make the round-trip fail.But
+is the one actively breaking base64 cursors, because the backend reads params throughURLSearchParams, which decodes+as a space.#111 asserts on the wire form instead (
XCTAssertFalse(rawQuery.contains("+"))), which is thecheck that actually bites. Worth remembering for the next encoding test: assert what goes on the
wire, not what a client-side parser hands back.
Outcome
plus the
dmThreadUpdatesaftercursor. Line numbers had drifted; the set had not.queryValue(_:)now lives inAPIClientTransport.swiftbesidepathSegment(_:), witha comment saying why they are not interchangeable.
tests, including the three pre-existing ones from feat(messages): group the DM inbox by conversation #86, fix(identities): send unlink provider as a query param #93 and the org search. The path-encoding
guard correctly stayed green.
One behaviour delta, harmless
The old
orgQueryValueAllowedstripped&=+?#but not/; the shared set also strips/. Orgsearch values now go over the wire with
%2Frather than a bare slash — strictly more encoding,decoding identically server-side.
excludeMemberscommas are untouched, since,is not in thestrip set, and the test pinning "the backend splits this list on a literal comma" still passes.
Noted, not fixed
About a dozen path segments are still encoded inline as
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? ideven thoughpathSegment(_:)exists for exactly that. Correct as written, just not using the helper — samerule-of-three argument, but a separate sweep. Filed as #112.