Skip to content

fix: merge searchParams with input URL#840

Merged
sindresorhus merged 12 commits intosindresorhus:mainfrom
AkaHarshit:fix/merge-search-params
Mar 28, 2026
Merged

fix: merge searchParams with input URL#840
sindresorhus merged 12 commits intosindresorhus:mainfrom
AkaHarshit:fix/merge-search-params

Conversation

@AkaHarshit
Copy link
Copy Markdown
Contributor

Problem

Currently, providing searchParams in Ky options will completely overwrite any query parameters present in the input URL. This contradicts how most fetch libraries behave and poses difficulties for seamless URL abstractions. For more information, see #836.

Solution

This PR changes the searchParams behavior to merge and append onto existing query parameters instead of completely replacing them.
Additionally, mirroring how options.headers works, users can explicitly set an option to undefined (example { foo: undefined }) to delete a pre-existing query parameter on the original URL.

Testing

  • Added tests asserting query merging mechanics between input URL and options.searchParams
  • Added tests verifying { key: undefined } successfully drops elements from the Query String.
  • Verified all AVA test checks run successfully locally.

Fixes #836

@sindresorhus
Copy link
Copy Markdown
Owner

This is clearly AI-generated, so I'm already not trusting it. And I think we should agree on behavior in #836. The implementation is the easy part. Getting the behavior right is the hard part. For example, how it should work with .extend() where different layers use URLSearchParam and object. Should undefined to nil a key work at the top-level only or at any level. Etc.

@sholladay
Copy link
Copy Markdown
Collaborator

sholladay commented Mar 21, 2026

Any level, IMO. That is, for any enumerable key whose value is undefined.

@AkaHarshit
Copy link
Copy Markdown
Contributor Author

I have updated the implementation to handle undefined properly at any level. Deeply merged searchParams layers now use a non-enumerable symbol internally to track explicit deletions, ensuring that keys are removed from the final input URL correctly even when mixed with URLSearchParams. I have also added a test to ensure the literal string 'undefined' does not trigger deletion tracking.

… deletion-only params

- Added 6 test cases suggested by sindresorhus in PR review
- Fixed hasSearchParameters to handle URLSearchParams with only deletedParametersSymbol entries
- Marked init hook test as skip since init hooks are not yet implemented
@AkaHarshit
Copy link
Copy Markdown
Contributor Author

Thanks for the suggested tests @sindresorhus! I've added all 6 test cases from your review:

  • deletes merged search params even when all additions are removed by undefined
  • request searchParams undefined removes merged keys but keeps unrelated values
  • string searchParams merge keeps duplicates across input URL and defaults
  • ⏭️ init hook can delete merged search params via undefined — marked as test.skip since init hooks don't exist yet in ky's hook system
  • ky.extend() searchParams layer deletion propagates through merged instances
  • searchParams option merges with existing query when hash is present

I also had to fix hasSearchParameters() in options.ts — it was returning false for a URLSearchParams with size === 0 even when it carried deletedParametersSymbol entries (i.e., all values were deleted via undefined). This caused the deletion logic in Ky.ts to be skipped entirely.

Note on the hash test: The expected value was adjusted from ?old&foo=1#hash to ?old=&foo=1#hash because the URL parser normalizes valueless keys (?old?old=).

All 15 tests pass locally (1 skipped for init hooks).

sindresorhus and others added 2 commits March 26, 2026 01:00
- Use template literals instead of string concatenation with server.url
- Replace test.skip with test.todo for init hook test (ava/no-skip-test)
@sholladay
Copy link
Copy Markdown
Collaborator

init hooks were merged in #841. That test should be included.

@AkaHarshit
Copy link
Copy Markdown
Contributor Author

I've replaced the test.todo for the init hook with a real test verifying that the hook can delete merged searchParams, now that the init hooks PR (#841) is merged into main and available.

Accepts any value supported by [`URLSearchParams()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams).

When passing an object, `undefined` values are automatically filtered out, while `null` values are preserved and converted to the string `'null'`.
When passing an object, setting a value to `undefined` deletes the parameter, while `null` values are preserved and converted to the string `'null'`.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TS doc comments needs to be updated too.

@sindresorhus
Copy link
Copy Markdown
Owner

I would add a few more tests:

  1. init hook deletion over merged defaults and input URL
    This is the most important one if it is not already in the final branch.
    It should prove all three layers interact correctly: input URL, ky.create() / .extend() defaults, and an init hook that sets {foo: undefined}.

  2. Re-adding a key after an earlier deletion across merge layers
    Example:

  • base defaults: {foo: '1'}
  • extended defaults: {foo: undefined}
  • request options: {foo: '2'}

Expected: foo=2

This verifies the deletion symbol is really layer-ordered state, not a permanent tombstone.

  1. Deletion from a replaceOption(...) boundary
    Example:
  • base defaults: {foo: '1', bar: '2'}
  • extended defaults: replaceOption({bar: undefined, baz: '3'})

Expected behavior should be explicit.

This is useful because replaceOption is the one place where merge semantics intentionally change.

  1. Duplicate-key deletion with URLSearchParams
    Example input URL: ?foo=1&foo=2&bar=3
    Request options: {foo: undefined}

Expected: all foo entries are removed, bar remains.

That confirms deletion semantics are per-key, not per-entry.

  1. Empty merged URLSearchParams plus deletion plus later append
    Example:
  • base defaults: new URLSearchParams({foo: '1'})
  • request options: {foo: undefined, bar: '2'}

Expected: only bar=2

This is close to existing tests, but it specifically exercises the deletion-only intermediate state plus a later addition in one request.

  1. Function-form .extend() with deletion
    Example:
  • base defaults: {foo: '1'}
  • extend(parent => ({searchParams: {foo: undefined, bar: '2'}}))

Expected: foo removed, bar added.

@AkaHarshit
Copy link
Copy Markdown
Contributor Author

I have updated the TS doc comments and added the 5 requested additional test cases for handling undefined searchParams across different layers and mutations (init hooks, replaceOption, duplicate keys, URLSearchParams overrides). All tests compile and pass successfully.


// Recreate request with the updated URL. We already have all options in this.#options, including duplex.
this.request = new globalThis.Request(url, this.#options as RequestInit);
this.request = new globalThis.Request(url.toString(), this.#options as RequestInit);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toString() is not necessary here

test/main.ts Outdated
});

test('merges searchParams with input URL', async t => {
const server = await createHttpTestServer();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each of the tests you've added, pass t to createHttpTestServer() so that it will close the server automatically at the end of the test. Then you can remove server.close() from those tests.

@AkaHarshit
Copy link
Copy Markdown
Contributor Author

Applied the final changes requested by the maintainer: removed toString() usage and passed t to createHttpTestServer in the new tests.

@sindresorhus sindresorhus merged commit 29e78fe into sindresorhus:main Mar 28, 2026
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.

Merge searchParams with what is specified in input URL

3 participants