47ng / 47ng/nuqs

Object-returning parsers lose referential equality for values parsed from the URL, causing render loops

Đang mở
#1,531 2 bình luận 0 reaction 0 người được giao Xem trên GitHub
Ngôn ngữ chính
TypeScript
Star
10.8k
Fork
294
Merge trung bình
1 ngày 16 giờ
Pull request đã merge (30 ngày)
21

Mô tả

### Summary

A parser whose `parse` returns an object (`Date`, `parseAsJson`) returns a new object identity on nearly every render, even when the query string and the value it encodes are unchanged. Consumers that treat the value as referentially stable, in a `useEffect` dependency array, a `key`, or a memo dep, observe a change that never happened. Where a remount is in the feedback path, the render loop does not settle.

### Environment

- nuqs `2.9.5` (also reproduced on `next` at `8ae4db1`)
- React 19
- Reproduced under both `@testing-library/react` + jsdom and nuqs's own `vitest-browser-react` browser project

### Failing tests

I have added three failing cases to the existing `useQueryState: referential equality` describe block, using your own helpers and browser runner:

https://github.com/Gjdoalfnrxu/nuqs/tree/test/date-parser-referential-equality

```
× should keep referential equality for a Date parsed from the URL
× should keep referential equality for a Date across remounts
× should not re-render indefinitely when a Date is reported upward
```

`pnpm --filter nuqs test:browser` reproduces. The other 25 tests in that file pass.

### Measured behaviour

Two subscribers on the same key, URL never written to, value never changes:

| parser | renders (stays mounted) | distinct identities | renders (re-keyed) | distinct identities |
|---|---|---|---|---|
| `parseAsString` | 4 | 1 | 6 | 1 |
| `parseAsIsoDateTime` | 4 | **2** | 6 | **6** |

Every one of those `Date` objects carries the same instant. A freshly mounted consumer gets a new object on every render; a consumer that merely stays mounted still sees its value swapped once.

### The loop

The third test wires the arrangement this shows up as in real code, with **no writes to the URL anywhere**:

1. a component holds state `S` and re-keys a child whenever `S` changes identity (a menu re-keying its items after a measure)
2. the child reads the param and reports its value upward
3. the report changes `S`'s identity, which re-keys the child, which re-parses, which mints a new object

It throws at 53 renders and is still climbing. Swapping the parser for `parseAsString` settles it in 3.

### Where it comes from, as far as I can tell

`parseMap` caches on the raw query string, and the cache lives in a ref owned by a single hook instance. So a value cached by one hook is invisible to every other instance, which re-parses from scratch, and any newly mounted instance necessarily produces a new object. `compareQuery` compares query strings rather than parsed values, and the emitter sync path compares with `Object.is`, which is why a parser's `eq` does not help here.

Would you mind a PR to add a simple sentence to the docs to make this behaviour clearer (if i didn't just miss it?) in the mean time?

---

I realised after creating these test cases that your existing WIP https://github.com/47ng/nuqs/pull/1482 solves for this failure (i think) so perhaps consider this a vote for upstreaming that, but this behaviour is surprising / a bit of a pain

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.