Skip to content

Feature/recursive transformations and register serializable class#351

Open
ZiadTaha62 wants to merge 10 commits intoflightcontrolhq:mainfrom
ZiadTaha62:feature/register-serializable-class
Open

Feature/recursive transformations and register serializable class#351
ZiadTaha62 wants to merge 10 commits intoflightcontrolhq:mainfrom
ZiadTaha62:feature/register-serializable-class

Conversation

@ZiadTaha62
Copy link
Copy Markdown

@ZiadTaha62 ZiadTaha62 commented Mar 20, 2026

issue #350 code chages

Greptile Summary

This PR adds recursive custom transformations to SuperJSON: a recursive: true flag on custom transformers tells the walker to pass the serialized output back through the full SuperJSON pipeline, enabling nested non-JSON values (e.g. a Date inside a custom class) to be annotated and round-tripped without manually calling serialize recursively. It also fixes referential-equality handling for objects that share identity across transformation boundaries, and bumps the wire-format version to 2 to support a new .$<depth> path suffix that disambiguates annotation depth from path keys.\n\nKey changes:\n- custom-transformer-registry.ts: New discriminated-union types NonRecursiveCustomTransfomer / RecursiveCustomTransfomer with overloaded register and registerCustom signatures.\n- transformer.ts: All transformation rules now carry an isDeep flag; customRule delegates to transformer.recursive; transformValue returns isDeep alongside value and type.\n- plainer.ts: walker gains a samePathDepth counter that increments on each recursive transformation at the same JSON path; applyValueAnnotations / applyReferentialEqualityAnnotations are replaced by a single applyMeta that coordinates type-annotation application and referential-equality propagation in one pass.\n- pathstringifier.ts: escapeKey now escapes $-prefixed keys (to avoid collision with the .$<depth> suffix); parsePath adds a depthSegment parameter to strip the suffix during parse.\n- accessDeep.ts: Adds array bounds-checking guards.\n- referentialEquality.test.ts: New comprehensive test suite for referential-equality scenarios including recursive transformations, Map/Set, and circular references.\n\nOne P1 issue found: the \\$ escape added to parsePath is only stripped for the last path segment. Intermediate segments with $-prefixed keys are pushed into the result array with a literal backslash, causing setDeep to silently miss the target key during deserialisation. The existing test only exercises $ keys at the last position, so this case goes undetected.

Confidence Score: 4/5

Not safe to merge until the intermediate-segment \$-unescape bug in parsePath is fixed; all other concerns are minor.

One definite P1 correctness bug: any object whose annotation path contains a $-prefixed key at a non-terminal position will fail to deserialise correctly (the transformed value is silently dropped). All remaining findings are P2 style/test-design suggestions that do not block functionality.

src/pathstringifier.ts — the parsePath loop must handle the \$ escape mid-string, not only at the final segment.

Important Files Changed

Filename Overview
src/pathstringifier.ts Adds $-key escaping in escapeKey and depth-segment stripping in parsePath; the \$ escape is not consumed mid-loop, so intermediate $-prefixed path segments round-trip incorrectly.
src/plainer.ts Major rewrite: introduces samePathDepth tracking for recursive transformations, merges applyValueAnnotations/applyReferentialEqualityAnnotations into applyMeta, and adds ReferentialEqualityGroup to co-ordinate annotation + equality application; logic appears sound.
src/transformer.ts Adds isDeep flag to both simple and composite transformation rules; all existing rules assigned sensible values (Error/Set/Map/classRule = true, others = false), and customRule delegates to transformer.recursive.
src/custom-transformer-registry.ts Splits CustomTransfomer into NonRecursiveCustomTransfomer and RecursiveCustomTransfomer via discriminated union with an overloaded register; clean type-safe design.
src/index.ts Delegates to new applyMeta, bumps serialization version to 2, and adds registerCustom overloads for recursive transformers; straightforward wiring changes.
src/index.test.ts Updates version expectations from v:1 to v:2, adds a recursive-custom-transformer round-trip test, and a $-key test; the $-key test doesn't cover intermediate-path $ keys, leaving the introduced bug uncaught.
src/referentialEquality.test.ts New comprehensive referential-equality test suite covering recursive transformers, deduplication, Maps/Sets, and circular references; well-structured but uses shared module-level SuperJSON instances that accumulate registrations across tests.
src/accessDeep.ts Extracts the error message to a constant and adds bounds-checking guards for array access; safe defensive improvements.
src/types.ts Replaces inline meta type with the new MetaObject export from plainer.ts; purely structural cleanup.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["walker(object)"] --> B{isPrimitive?}
    B -- yes --> Z[return as-is]
    B -- no --> C[addIdentity + check seenObjects]
    C --> D{already seen?}
    D -- "yes, dedupe" --> E[return null]
    D -- "yes, no dedupe" --> F[return cached result]
    D -- no --> G{circular?}
    G -- yes --> H[return null]
    G -- no --> I["transformValue(object)"]
    I --> J{match found?}
    J -- "no match" --> K{isPlainObject or Array?}
    K -- yes --> L["forEach child → walker(path+index, depth=0)"]
    K -- no --> Z
    L --> M[collect innerAnnotations]
    M --> N[return transformedValue + annotations map]
    J -- "match, isDeep=false" --> O[return transformedValue + annotation leaf]
    J -- "match, isDeep=true" --> P["walker(transformedValue, same path, depth+1)"]
    P --> Q{inner annotations?}
    Q -- yes --> R["annotations = [type, innerAnnotations]"]
    Q -- no --> S["annotations = [type]"]
    R --> T[cache in seenObjects, return]
    S --> T
Loading

Comments Outside Diff (7)

  1. src/index.test.ts, line 260-268 (link)

    P2 Test state pollution via shared static defaultInstance registry

    Every test in this file that calls SuperJSON.registerSerializableClass(User) mutates the global SuperJSON.defaultInstance (the shared registry). Because DoubleIndexedKV.set unconditionally overwrites the keyToValue map entry, each new User class registered with the identifier "User" silently replaces the previous one:

    // Test A registers UserA → keyToValue["User"] = UserA, valueToKey[UserA] = "User"
    // Test B registers UserB → keyToValue["User"] = UserB, valueToKey[UserB] = "User"
    // Now getByKey("User") → UserB, but getByValue(UserA) still returns "User"!

    This means the isInstanceOfSerializableClass check still returns true for UserA instances (since valueToKey[UserA] exists), but deserialization will use UserB instead of UserA.

    Currently the tests are resilient because each test that round-trips is self-contained (registers and immediately parse/stringify). But as the test suite grows, this fragility is a maintenance risk.

    Consider either:

    • Using new SuperJSON() instances inside each test instead of the static methods
    • Adding a clear() call on serializableClassRegistry in afterEach

    This issue also appears in tests at lines ~291, ~302, ~320, ~328, and ~343.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/index.test.ts
    Line: 260-268
    
    Comment:
    **Test state pollution via shared static `defaultInstance` registry**
    
    Every test in this file that calls `SuperJSON.registerSerializableClass(User)` mutates the global `SuperJSON.defaultInstance` (the shared registry). Because `DoubleIndexedKV.set` unconditionally overwrites the `keyToValue` map entry, each new `User` class registered with the identifier `"User"` silently replaces the previous one:
    
    ```typescript
    // Test A registers UserA → keyToValue["User"] = UserA, valueToKey[UserA] = "User"
    // Test B registers UserB → keyToValue["User"] = UserB, valueToKey[UserB] = "User"
    // Now getByKey("User") → UserB, but getByValue(UserA) still returns "User"!
    ```
    
    This means the `isInstanceOfSerializableClass` check still returns `true` for `UserA` instances (since `valueToKey[UserA]` exists), but deserialization will use `UserB` instead of `UserA`.
    
    Currently the tests are resilient because each test that round-trips is self-contained (registers and immediately parse/stringify). But as the test suite grows, this fragility is a maintenance risk.
    
    Consider either:
    - Using `new SuperJSON()` instances inside each test instead of the static methods
    - Adding a `clear()` call on `serializableClassRegistry` in `afterEach`
    
    This issue also appears in tests at lines ~291, ~302, ~320, ~328, and ~343.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/index.test.ts, line 151-171 (link)

    P2 Multiple typos in test names

    The following test names contain spelling errors:

    • Line 151: 'works for recrusive custom registry''works for recursive custom registry'
    • Line 300: 'throw if serilization/deserialization method is missing''throw if serialization/deserialization method is missing'
    • Line 320: 'Handles recrusive primitives and non-serilizable classes''Handles recursive primitives and non-serializable classes'

    These typos appear in the test description strings visible in test reports.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/index.test.ts
    Line: 151-171
    
    Comment:
    **Multiple typos in test names**
    
    The following test names contain spelling errors:
    
    - Line 151: `'works for recrusive custom registry'``'works for recursive custom registry'`
    - Line 300: `'throw if serilization/deserialization method is missing'``'throw if serialization/deserialization method is missing'`
    - Line 320: `'Handles recrusive primitives and non-serilizable classes'``'Handles recursive primitives and non-serializable classes'`
    
    These typos appear in the test description strings visible in test reports.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. src/index.test.ts, line 220-246 (link)

    P1 Test uses wrong SuperJSON instance — custom transformer is registered on the global default, not the dedupe instance

    SuperJSON.registerCustom(...) delegates to SuperJSON.defaultInstance. The serialization and deserialization, however, use instance = new SuperJSON({ dedupe: true }), which has its own fresh customTransformerRegistry. Because Box is not registered on instance, the transformer is never applied during instance.serialize(input). The test does not actually exercise recursive custom transformers with dedupe: true.

    Change SuperJSON.registerCustom to instance.registerCustom so the transformer is registered on the same instance used for serialization:

    test('dedupe=true with recursive custom transformer', () => {
      class Box {
        constructor(public value: any) {}
      }
    
      const instance = new SuperJSON({ dedupe: true });
    
      instance.registerCustom(
        {
          isApplicable: (v): v is Box => v instanceof Box,
          serialize: (v: Box) => v.value,
          deserialize: (v: any) => new Box(v),
          recursive: true,
        },
        'box-dedupe'
      );
    
      // ... rest of test
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/index.test.ts
    Line: 220-246
    
    Comment:
    **Test uses wrong SuperJSON instance — custom transformer is registered on the global default, not the dedupe instance**
    
    `SuperJSON.registerCustom(...)` delegates to `SuperJSON.defaultInstance`. The serialization and deserialization, however, use `instance = new SuperJSON({ dedupe: true })`, which has its own fresh `customTransformerRegistry`. Because `Box` is not registered on `instance`, the transformer is never applied during `instance.serialize(input)`. The test does not actually exercise recursive custom transformers with `dedupe: true`.
    
    Change `SuperJSON.registerCustom` to `instance.registerCustom` so the transformer is registered on the same instance used for serialization:
    
    ```ts
    test('dedupe=true with recursive custom transformer', () => {
      class Box {
        constructor(public value: any) {}
      }
    
      const instance = new SuperJSON({ dedupe: true });
    
      instance.registerCustom(
        {
          isApplicable: (v): v is Box => v instanceof Box,
          serialize: (v: Box) => v.value,
          deserialize: (v: any) => new Box(v),
          recursive: true,
        },
        'box-dedupe'
      );
    
      // ... rest of test
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  4. src/index.test.ts, line 220-246 (link)

    P1 Custom transformer registered on wrong SuperJSON instance

    SuperJSON.registerCustom(...) is a static method bound to SuperJSON.defaultInstance:

    static registerCustom = SuperJSON.defaultInstance.registerCustom.bind(SuperJSON.defaultInstance);

    It registers the 'box-dedupe' transformer only on SuperJSON.defaultInstance.customTransformerRegistry. However, the serialization is performed on a fresh instance = new SuperJSON({ dedupe: true }), which has its own independent customTransformerRegistry (initialized as new CustomTransformerRegistry() per the class field declaration). The fresh instance has no knowledge of 'box-dedupe'.

    As a result, when instance.serialize(input) walks new Box(shared):

    1. No transformer is found in instance.customTransformerRegistry
    2. isPlainObjectOrArray(new Box(...)) is false (Box prototype ≠ Object.prototype)
    3. The Box instance is returned as-is and falls through to JSON serialization as a plain object
    4. The shared reference inside each Box is never tracked by SuperJSON's dedupe logic

    The assertion expect(result.first.value).toBe(result.second.value) will fail because both values are deserialized as separate plain objects.

    The fix is to register the transformer on instance rather than the global default:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/index.test.ts
    Line: 220-246
    
    Comment:
    **Custom transformer registered on wrong SuperJSON instance**
    
    `SuperJSON.registerCustom(...)` is a static method bound to `SuperJSON.defaultInstance`:
    
    ```js
    static registerCustom = SuperJSON.defaultInstance.registerCustom.bind(SuperJSON.defaultInstance);
    ```
    
    It registers the `'box-dedupe'` transformer only on `SuperJSON.defaultInstance.customTransformerRegistry`. However, the serialization is performed on a fresh `instance = new SuperJSON({ dedupe: true })`, which has its **own independent** `customTransformerRegistry` (initialized as `new CustomTransformerRegistry()` per the class field declaration). The fresh instance has no knowledge of `'box-dedupe'`.
    
    As a result, when `instance.serialize(input)` walks `new Box(shared)`:
    1. No transformer is found in `instance.customTransformerRegistry`
    2. `isPlainObjectOrArray(new Box(...))` is `false` (Box prototype ≠ `Object.prototype`)
    3. The Box instance is returned as-is and falls through to JSON serialization as a plain object
    4. The `shared` reference inside each Box is never tracked by SuperJSON's dedupe logic
    
    The assertion `expect(result.first.value).toBe(result.second.value)` will fail because both values are deserialized as separate plain objects.
    
    The fix is to register the transformer on `instance` rather than the global default:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  5. src/pathstringifier.ts, line 27-43 (link)

    P1 \$ escape not consumed — backslash leaks into parsed segment

    escapeKey('$foo') produces the string \$foo (literal backslash + dollar). When parsePath later reads that string, the escape-validation block correctly allows \$ without throwing, but it does not consume the backslash the way \. is consumed. The isEscapedDot branch below handles \. properly (adds . to segment and skips via i++), but there is no equivalent isEscapedDollar branch. The \ character therefore falls through to segment += char and ends up in the final segment.

    Concrete trace for key $foo:

    escapeKey('$foo') → '\$foo'   (literal backslash-dollar-foo)
    
    parsePath('\$foo', false, true):
      i=0 char='\'  escaped='$'  → allowed, fall-through
      isEscapedDot  → false (next is '$', not '.')
      isEndOfSegment → false
      segment += '\'              → segment = '\'       ← backslash stuck
      i=1 char='$' → segment = '\$'
      i=2…4 → segment = '\$foo'
      lastSegment[0] = '\' ≠ '$' → pushed to result
    
    Result: ['\$foo']  ← wrong, should be ['$foo']
    

    setDeep(json, ['\$foo'], ...) then looks for the property '\\$foo' in the object, which doesn't exist — the annotation is silently skipped and the value is never deserialized. Any object with a key starting with $ (e.g. MongoDB-style selectors like $gt, $in, or GraphQL variables like $variables) will produce corrupted output when serialized under v=2.

    A fix needs to add explicit handling for the \$ escape, similar to how \. is handled:

    const isEscapedDollar = char === '\\' && string.charAt(i + 1) === '$';
    if (isEscapedDollar) {
      segment += '$';
      i++;
      continue;
    }

    This must be placed before the isEscapedDot check so that \$ is consumed before the generic fall-through to segment += char.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/pathstringifier.ts
    Line: 27-43
    
    Comment:
    **`\$` escape not consumed — backslash leaks into parsed segment**
    
    `escapeKey('$foo')` produces the string `\$foo` (literal backslash + dollar). When `parsePath` later reads that string, the escape-validation block correctly allows `\$` without throwing, but it does **not** consume the backslash the way `\.` is consumed. The `isEscapedDot` branch below handles `\.` properly (adds `.` to segment and skips via `i++`), but there is no equivalent `isEscapedDollar` branch. The `\` character therefore falls through to `segment += char` and ends up in the final segment.
    
    Concrete trace for key `$foo`:
    ```
    escapeKey('$foo') → '\$foo'   (literal backslash-dollar-foo)
    
    parsePath('\$foo', false, true):
      i=0 char='\'  escaped='$'  → allowed, fall-through
      isEscapedDot  → false (next is '$', not '.')
      isEndOfSegment → false
      segment += '\'              → segment = '\'       ← backslash stuck
      i=1 char='$' → segment = '\$'
      i=2…4 → segment = '\$foo'
      lastSegment[0] = '\' ≠ '$' → pushed to result
    
    Result: ['\$foo']  ← wrong, should be ['$foo']
    ```
    
    `setDeep(json, ['\$foo'], ...)` then looks for the property `'\\$foo'` in the object, which doesn't exist — the annotation is silently skipped and the value is never deserialized. Any object with a key starting with `$` (e.g. MongoDB-style selectors like `$gt`, `$in`, or GraphQL variables like `$variables`) will produce corrupted output when serialized under v=2.
    
    A fix needs to add explicit handling for the `\$` escape, similar to how `\.` is handled:
    ```typescript
    const isEscapedDollar = char === '\\' && string.charAt(i + 1) === '$';
    if (isEscapedDollar) {
      segment += '$';
      i++;
      continue;
    }
    ```
    This must be placed **before** the `isEscapedDot` check so that `\$` is consumed before the generic fall-through to `segment += char`.
    
    How can I resolve this? If you propose a fix, please make it concise.
  6. src/pathstringifier.ts, line 27-43 (link)

    P1 \$ escape sequence is validated but never decoded

    escapeKey now encodes keys starting with $ as \$key (e.g. $foo\$foo), but parsePath never consumes the \ before $. The escape check on line 33 allows the character through without erroring, then the backslash falls into segment += char (line 52) and the next iteration adds $. The decoded segment becomes \$foo instead of $foo.

    Concretely: any object with a key starting with $ will silently fail to round-trip in v2 — setDeep/getDeep will target key \$foo on the JSON object instead of the real key $foo, meaning the annotation is never applied.

    The fix is to add an isEscapedDollar branch analogous to the existing isEscapedDot branch:

    Without this fix, any serialized object whose keys start with $ (e.g. MongoDB projections, GraphQL variables, AngularJS bindings) will silently drop their type annotations or referential-equality links.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/pathstringifier.ts
    Line: 27-43
    
    Comment:
    **`\$` escape sequence is validated but never decoded**
    
    `escapeKey` now encodes keys starting with `$` as `\$key` (e.g. `$foo``\$foo`), but `parsePath` never consumes the `\` before `$`. The escape check on line 33 allows the character through without erroring, then the backslash falls into `segment += char` (line 52) and the next iteration adds `$`. The decoded segment becomes `\$foo` instead of `$foo`.
    
    Concretely: any object with a key starting with `$` will silently fail to round-trip in v2 — `setDeep`/`getDeep` will target key `\$foo` on the JSON object instead of the real key `$foo`, meaning the annotation is never applied.
    
    The fix is to add an `isEscapedDollar` branch analogous to the existing `isEscapedDot` branch:
    
    
    
    Without this fix, any serialized object whose keys start with `$` (e.g. MongoDB projections, GraphQL variables, AngularJS bindings) will silently drop their type annotations or referential-equality links.
    
    How can I resolve this? If you propose a fix, please make it concise.
  7. src/pathstringifier.ts, line 24-61 (link)

    P1 \$ escape only stripped from the last path segment

    The parsePath loop validates \$ as a legal escape sequence (line 33) but never consumes it the way \. is consumed (lines 38–43). As a result, when \$ appears in an intermediate segment, the literal backslash is added to segment and pushed into result as \$key instead of $key.

    The corrective code on lines 55–58 (segment.slice(1)) only runs for the last segment; intermediate segments are pushed raw on line 47. This means any object whose annotation path has a $-prefixed key at a non-terminal position will deserialize incorrectly.

    Concrete failure:

    const input = { a: { $middle: { b: new Date() } } };

    Serialisation produces annotation key a.\$middle.b. When parsePath('a.\\$middle.b', false, true) is called during deserialisation, \$middle is an intermediate segment and is pushed as \$middle (with literal backslash) instead of $middle. setDeep then silently misses the key.

    The 'Handle Doller at start of string correctly' test does not expose this because its $-prefixed keys only appear at the last position in annotation paths.

    The fix is to handle \$ the same way \. is handled — consume the escape and add just $ to the segment:

    const isEscapedDollar = char === '\\' && string.charAt(i + 1) === '$';
    if (isEscapedDollar) {
      segment += '$';
      i++;
      continue;
    }

    Then the final last-segment special-case on lines 55–58 can be simplified back to result.push(segment).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/pathstringifier.ts
    Line: 24-61
    
    Comment:
    **`\$` escape only stripped from the last path segment**
    
    The `parsePath` loop validates `\$` as a legal escape sequence (line 33) but never *consumes* it the way `\.` is consumed (lines 38–43). As a result, when `\$` appears in an intermediate segment, the literal backslash is added to `segment` and pushed into `result` as `\$key` instead of `$key`.
    
    The corrective code on lines 55–58 (`segment.slice(1)`) only runs for the **last** segment; intermediate segments are pushed raw on line 47. This means any object whose annotation path has a `$`-prefixed key at a non-terminal position will deserialize incorrectly.
    
    **Concrete failure:**
    ```ts
    const input = { a: { $middle: { b: new Date() } } };
    ```
    
    Serialisation produces annotation key `a.\$middle.b`. When `parsePath('a.\\$middle.b', false, true)` is called during deserialisation, `\$middle` is an intermediate segment and is pushed as `\$middle` (with literal backslash) instead of `$middle`. `setDeep` then silently misses the key.
    
    The `'Handle Doller at start of string correctly'` test does not expose this because its `$`-prefixed keys only appear at the **last** position in annotation paths.
    
    The fix is to handle `\$` the same way `\.` is handled — consume the escape and add just `$` to the segment:
    ```ts
    const isEscapedDollar = char === '\\' && string.charAt(i + 1) === '$';
    if (isEscapedDollar) {
      segment += '$';
      i++;
      continue;
    }
    ```
    Then the final last-segment special-case on lines 55–58 can be simplified back to `result.push(segment)`.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/pathstringifier.ts
Line: 24-61

Comment:
**`\$` escape only stripped from the last path segment**

The `parsePath` loop validates `\$` as a legal escape sequence (line 33) but never *consumes* it the way `\.` is consumed (lines 38–43). As a result, when `\$` appears in an intermediate segment, the literal backslash is added to `segment` and pushed into `result` as `\$key` instead of `$key`.

The corrective code on lines 55–58 (`segment.slice(1)`) only runs for the **last** segment; intermediate segments are pushed raw on line 47. This means any object whose annotation path has a `$`-prefixed key at a non-terminal position will deserialize incorrectly.

**Concrete failure:**
```ts
const input = { a: { $middle: { b: new Date() } } };
```

Serialisation produces annotation key `a.\$middle.b`. When `parsePath('a.\\$middle.b', false, true)` is called during deserialisation, `\$middle` is an intermediate segment and is pushed as `\$middle` (with literal backslash) instead of `$middle`. `setDeep` then silently misses the key.

The `'Handle Doller at start of string correctly'` test does not expose this because its `$`-prefixed keys only appear at the **last** position in annotation paths.

The fix is to handle `\$` the same way `\.` is handled — consume the escape and add just `$` to the segment:
```ts
const isEscapedDollar = char === '\\' && string.charAt(i + 1) === '$';
if (isEscapedDollar) {
  segment += '$';
  i++;
  continue;
}
```
Then the final last-segment special-case on lines 55–58 can be simplified back to `result.push(segment)`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/pathstringifier.ts
Line: 55-57

Comment:
**Unused variable `lastSegment`**

`lastSegment` is assigned from `segment` but the body of the `if` uses `segment` directly. The variable adds no value.

```suggestion
  if (!depthSegment || segment[0] !== '$') {
    result.push(segment.slice(0, 2) === '\\$' ? segment.slice(1) : segment);
  }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/index.test.ts
Line: 110

Comment:
**Typo in test name**

"recrusive" should be "recursive".

```suggestion
    'works for recursive custom registry': {
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/referentialEquality.test.ts
Line: 1377-1406

Comment:
**Shared `SuperJSON` instances accumulate registrations across all tests**

`createEqualityTests()` is called once at module scope, producing two `SuperJSON` instances shared by every test. Each `registerCustom` call permanently adds a transformer to those instances. The distinct class names used today prevent collisions, but a future test re-using a name will silently overwrite an earlier transformer.

Consider calling `createEqualityTests()` inside each `test` body or in a `beforeEach` hook so each test starts with clean instances.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (6): Last reviewed commit: "fix: handle '$' in parse function" | Re-trigger Greptile

- New SerializableClassRegistry
- registerSerializableClass method
- New 'serializable-class' transformer rule
- Update plainer.ts for deep traversal
- Add 4 new tests
fix: fixed issue where serializable class return non deep value
added: recursive option in custom transformation
src/plainer.ts Outdated
Comment on lines 191 to 201
@@ -210,84 +201,110 @@ export const walker = (
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Data loss with dedupe: true and shared object references in recursive transformations

When SuperJSON is created with { dedupe: true } and a serializable class's toSuperJSON() returns an object reference that was already serialized at another path in the same data structure, the current code will:

  1. Skip addIdentity(transformedValue, path, identities) because isTransformation = true
  2. Hit the seenObjects.get(object) cache and return { transformedValue: null } (dedupe path)
  3. No referential equality annotation is generated (since identities has only the original path)

The result is that the serialized JSON has null at the container's position with no annotation to restore it. On deserialization, deserializer.deserialize(null) is called instead of the correct value.

Example:

const sj = new SuperJSON({ dedupe: true });
const sharedObj = { x: 1 };
class Container { toSuperJSON() { return sharedObj; } }
sj.registerSerializableClass(Container);
const data = { direct: sharedObj, wrapped: new Container() };
sj.serialize(data); // wrapped.json will be null with no referential equality entry

The fix would be to call addIdentity even for isTransformation = true paths, but only after checking seenObjects, or alternatively, return seen (non-null) instead of null in this case.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plainer.ts
Line: 191-201

Comment:
**Data loss with `dedupe: true` and shared object references in recursive transformations**

When `SuperJSON` is created with `{ dedupe: true }` and a serializable class's `toSuperJSON()` returns an object reference that was already serialized at another path in the same data structure, the current code will:

1. Skip `addIdentity(transformedValue, path, identities)` because `isTransformation = true`
2. Hit the `seenObjects.get(object)` cache and return `{ transformedValue: null }` (dedupe path)
3. No referential equality annotation is generated (since `identities` has only the original path)

The result is that the serialized JSON has `null` at the container's position with no annotation to restore it. On deserialization, `deserializer.deserialize(null)` is called instead of the correct value.

Example:
```typescript
const sj = new SuperJSON({ dedupe: true });
const sharedObj = { x: 1 };
class Container { toSuperJSON() { return sharedObj; } }
sj.registerSerializableClass(Container);
const data = { direct: sharedObj, wrapped: new Container() };
sj.serialize(data); // wrapped.json will be null with no referential equality entry
```

The fix would be to call `addIdentity` even for `isTransformation = true` paths, but only after checking `seenObjects`, or alternatively, return `seen` (non-null) instead of `null` in this case.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I ran the earlier test:

test('Data loss with dedupe: true and shared object references in recursive transformations', () => {
  const sj = new SuperJSON({ dedupe: true });
  const sharedObj = { x: 1 };
  class Container {
    static fromSuperJSON() {
      return new Container();
    }
    toSuperJSON() {
      return sharedObj;
    }
  }
  sj.registerSerializableClass(Container);
  const data = { direct: sharedObj, wrapped: new Container() };
  const serialized = sj.serialize(data);
  const deserialized = sj.deserialize(serialized);

  console.debug('Data: ');
  console.dir(data, { depth: null });

  console.debug('Serialized: ');
  console.dir(serialized, { depth: null });

  console.debug('Deserialized: ');
  console.dir(deserialized, { depth: null });

  expect(deserialized).toEqual(data);
});

The test passed and these were the logs:

stdout | src/index.test.ts > Data loss with dedupe: true and shared object references in recursive transformations
Data:
{ direct: { x: 1 }, wrapped: Container {} }
Serialized:
{
  json: { direct: { x: 1 }, wrapped: null },
  meta: {
    values: { wrapped: [ [ 'serializable-class', 'Container' ] ] },
    v: 1
  }
}
Deserialized:
{ direct: { x: 1 }, wrapped: Container {} }

I see the issue now, even if the example test succeeded, the meta object of serialized is missing referentialEqualities, which will cause bugs as null will be passed to static fromSuperJSON

I will see what can i do to avoid this

Comment on lines 10 to 16
}

export class CustomTransformerRegistry {
private transfomers: Record<string, CustomTransfomer<any, any>> = {};

register<I, O extends JSONValue>(transformer: CustomTransfomer<I, O>) {
register<I, O extends SuperJSONValue>(transformer: CustomTransfomer<I, O>) {
this.transfomers[transformer.name] = transformer;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Overly permissive type constraint breaks non-recursive custom transformers

Changing O extends JSONValue to O extends SuperJSONValue is intended to support recursive: true transformers (where the serialized value itself gets further processed by SuperJSON). However, this type change also allows non-recursive transformers to declare a non-JSON output type (like Date, Set, Map, etc.) without recursive: true.

When a non-recursive custom transformer returns a non-JSON value (e.g., Date), the walker returns early:

if (!isDeep) {
  return { transformedValue: value, annotations: [type] };
}

The value (a Date instance) is stored as transformedValue. When JSON.stringify is called it serializes the Date as an ISO string via JavaScript's default toJSON(). But on deserialization, transformer.deserialize(isoString) receives a string — not the Date object the serializer produced. This is a silently incorrect round-trip.

Consider using a discriminated union to restrict the O type based on the recursive flag:

export interface CustomTransfomer<I, O extends JSONValue> {
  name: string;
  isApplicable: (v: any) => v is I;
  serialize: (v: I) => O;
  deserialize: (v: O) => I;
  recursive?: false;
}

export interface RecursiveCustomTransfomer<I, O extends SuperJSONValue> {
  name: string;
  isApplicable: (v: any) => v is I;
  serialize: (v: I) => O;
  deserialize: (v: O) => I;
  recursive: true;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/custom-transformer-registry.ts
Line: 10-16

Comment:
**Overly permissive type constraint breaks non-recursive custom transformers**

Changing `O extends JSONValue` to `O extends SuperJSONValue` is intended to support `recursive: true` transformers (where the serialized value itself gets further processed by SuperJSON). However, this type change also allows non-recursive transformers to declare a non-JSON output type (like `Date`, `Set`, `Map`, etc.) without `recursive: true`.

When a non-recursive custom transformer returns a non-JSON value (e.g., `Date`), the walker returns early:
```typescript
if (!isDeep) {
  return { transformedValue: value, annotations: [type] };
}
```

The `value` (a `Date` instance) is stored as `transformedValue`. When `JSON.stringify` is called it serializes the `Date` as an ISO string via JavaScript's default `toJSON()`. But on deserialization, `transformer.deserialize(isoString)` receives a **string** — not the `Date` object the serializer produced. This is a silently incorrect round-trip.

Consider using a discriminated union to restrict the `O` type based on the `recursive` flag:
```typescript
export interface CustomTransfomer<I, O extends JSONValue> {
  name: string;
  isApplicable: (v: any) => v is I;
  serialize: (v: I) => O;
  deserialize: (v: O) => I;
  recursive?: false;
}

export interface RecursiveCustomTransfomer<I, O extends SuperJSONValue> {
  name: string;
  isApplicable: (v: any) => v is I;
  serialize: (v: I) => O;
  deserialize: (v: O) => I;
  recursive: true;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The updated type from JSONValue to SuperJSONValue was done to support recursive custom transformations if needed as i found it useful and enable complex custom transformations without need to call nested serialize to every class or non JSON value
However using discriminated union for it will give much better type safety indeed

src/plainer.ts Outdated
objectsInThisPath: any[] = [],
seenObjects = new Map<unknown, Result>()
seenObjects = new Map<unknown, Result>(),
isTransformation: boolean = false // Prevent overwrite of original object in identities map in recursive transformtion
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Internal flag exposed in public function signature

The isTransformation parameter is an internal implementation detail — it controls whether addIdentity is called during a recursive deep-transform pass. Since walker is exported from plainer.ts and called from index.ts, this flag is part of the de-facto public API. External callers that pass isTransformation: true would bypass the identity tracking and produce incorrect referential-equality annotations.

Consider wrapping the recursive call logic inside a private helper (e.g., walkerInternal) and keeping only the public-facing signature clean:

export const walker = (
  object: any,
  identities: Map<any, any[][]>,
  superJson: SuperJSON,
  dedupe: boolean,
  path: any[] = [],
  objectsInThisPath: any[] = [],
  seenObjects = new Map<unknown, Result>()
): Result => walkerInternal(object, identities, superJson, dedupe, path, objectsInThisPath, seenObjects, false);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plainer.ts
Line: 186

Comment:
**Internal flag exposed in public function signature**

The `isTransformation` parameter is an internal implementation detail — it controls whether `addIdentity` is called during a recursive deep-transform pass. Since `walker` is exported from `plainer.ts` and called from `index.ts`, this flag is part of the de-facto public API. External callers that pass `isTransformation: true` would bypass the identity tracking and produce incorrect referential-equality annotations.

Consider wrapping the recursive call logic inside a private helper (e.g., `walkerInternal`) and keeping only the public-facing signature clean:

```typescript
export const walker = (
  object: any,
  identities: Map<any, any[][]>,
  superJson: SuperJSON,
  dedupe: boolean,
  path: any[] = [],
  objectsInThisPath: any[] = [],
  seenObjects = new Map<unknown, Result>()
): Result => walkerInternal(object, identities, superJson, dedupe, path, objectsInThisPath, seenObjects, false);
```

How can I resolve this? If you propose a fix, please make it concise.

feat: removed serializable class registry to focus on one thing a time
@ZiadTaha62 ZiadTaha62 requested a review from Skn0tt as a code owner March 24, 2026 00:34
Comment on lines 59 to 65
if (children) {
forEach(children, (child, key) => {
traverse(child, walker, version, [
...origin,
...parsePath(key, legacyPaths),
]);
});
if (isArray(children)) {
traverse(children, walker, legacyPaths, origin);
} else {
traverseObject(children, walker, legacyPaths, origin, strOrigin);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 strOrigin not propagated for recursive annotation children — causes incorrect deserialization

When an InnerNode's children is itself a Tree<T> (the case for recursive custom transformers, where the annotation looks like [['custom','Box'], ['Date']]), traverse calls itself without forwarding strOrigin. This means the walker callback is invoked with strPath = undefined for the inner annotation.

In setValueAnnotationsFn, the guard that prevents re-applying annotations to duplicate paths relies on strPath:

if (strPath && duplicate.has(strPath)) return;

Because strPath is undefined, this guard never fires for inner annotations of recursive transformers.

Concrete failure (dedupe=false, same recursive transformer instance at two paths):

const box = new Box(new Date());
const data = { a: box, b: box };  // same instance
  • After serialization, both a and b get annotation [['custom','Box'],['Date']].
  • After the representative path 'a' is fully transformed (Box → Date → value set; ref-eq sets b = a), the walker continues to process 'b''s annotation tree.
  • The inner 'Date' annotation fires with strPath = undefined, so duplicate.has('b') is never checked.
  • json.b is already a Box instance at this point; new Date(Box instance) produces Invalid Date.

Fix: pass strOrigin through the array branch, just as the object branch does:

Suggested change
if (children) {
forEach(children, (child, key) => {
traverse(child, walker, version, [
...origin,
...parsePath(key, legacyPaths),
]);
});
if (isArray(children)) {
traverse(children, walker, legacyPaths, origin);
} else {
traverseObject(children, walker, legacyPaths, origin, strOrigin);
}
}
if (isArray(children)) {
traverse(children, walker, legacyPaths, origin, strOrigin);
} else {
traverseObject(children, walker, legacyPaths, origin, strOrigin);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plainer.ts
Line: 59-65

Comment:
**`strOrigin` not propagated for recursive annotation children — causes incorrect deserialization**

When an `InnerNode`'s `children` is itself a `Tree<T>` (the case for recursive custom transformers, where the annotation looks like `[['custom','Box'], ['Date']]`), `traverse` calls itself without forwarding `strOrigin`. This means the `walker` callback is invoked with `strPath = undefined` for the inner annotation.

In `setValueAnnotationsFn`, the guard that prevents re-applying annotations to *duplicate* paths relies on `strPath`:
```ts
if (strPath && duplicate.has(strPath)) return;
```
Because `strPath` is `undefined`, this guard never fires for inner annotations of recursive transformers.

**Concrete failure** (dedupe=false, same recursive transformer instance at two paths):
```ts
const box = new Box(new Date());
const data = { a: box, b: box };  // same instance
```
- After serialization, both `a` and `b` get annotation `[['custom','Box'],['Date']]`.  
- After the representative path `'a'` is fully transformed (Box → Date → value set; ref-eq sets `b = a`), the walker continues to process `'b'`'s annotation tree.  
- The inner `'Date'` annotation fires with `strPath = undefined`, so `duplicate.has('b')` is never checked.  
- `json.b` is already a `Box` instance at this point; `new Date(Box instance)` produces `Invalid Date`.

Fix: pass `strOrigin` through the array branch, just as the object branch does:
```suggestion
  if (isArray(children)) {
    traverse(children, walker, legacyPaths, origin, strOrigin);
  } else {
    traverseObject(children, walker, legacyPaths, origin, strOrigin);
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 185 to 199
function compositeTransformation<I, O, A extends CompositeTypeAnnotation>(
isApplicable: (v: any, superJson: SuperJSON) => v is I,
annotation: (v: I, superJson: SuperJSON) => A,
transform: (v: I, superJson: SuperJSON) => O,
untransform: (v: O, a: A, superJson: SuperJSON) => I
untransform: (v: O, a: A, superJson: SuperJSON) => I,
isDeep: (v: O, superJson: SuperJSON) => boolean
) {
return {
isApplicable,
annotation,
transform,
untransform,
isDeep,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 isDeep type parameter is O (output) but receives I (input) at the call site

The compositeTransformation helper declares isDeep as:

isDeep: (v: O, superJson: SuperJSON) => boolean

where O is the transformed/output type. However, in transformValue the function is called with the original value (type I, before transformation):

isDeep: applicableCompositeRule.isDeep(value, superJson), // value is I, not O

All current implementations happen to work correctly despite this mismatch:

  • classRule, symbolRule, typedArrayRule ignore v entirely (() => true/false)
  • customRule calls findApplicable(value) which uses isApplicable — a check on the original type I — so it still finds the right transformer

But the declared type misleads any future contributor who adds a new composite rule and assumes v is the post-transformation value. The parameter should be typed (v: I, superJson: SuperJSON) => boolean to accurately describe what is passed at runtime.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transformer.ts
Line: 185-199

Comment:
**`isDeep` type parameter is `O` (output) but receives `I` (input) at the call site**

The `compositeTransformation` helper declares `isDeep` as:
```ts
isDeep: (v: O, superJson: SuperJSON) => boolean
```
where `O` is the **transformed/output** type. However, in `transformValue` the function is called with the **original** value (type `I`, before transformation):
```ts
isDeep: applicableCompositeRule.isDeep(value, superJson), // value is I, not O
```

All current implementations happen to work correctly despite this mismatch:
- `classRule`, `symbolRule`, `typedArrayRule` ignore `v` entirely (`() => true/false`)
- `customRule` calls `findApplicable(value)` which uses `isApplicable` — a check on the original type `I` — so it still finds the right transformer

But the declared type misleads any future contributor who adds a new composite rule and assumes `v` is the post-transformation value. The parameter should be typed `(v: I, superJson: SuperJSON) => boolean` to accurately describe what is passed at runtime.

How can I resolve this? If you propose a fix, please make it concise.

@ZiadTaha62
Copy link
Copy Markdown
Author

I removed the earlier serializable-class-registry approach and focused on supporting recursive custom transformations. The main change allows the walker to continue traversal on the result of a custom serialize, and updates the deserialization logic accordingly to handle this.

There is one known limitation: if a custom transformer returns an object that is also referenced elsewhere in the original structure, both paths overlap. Correctly preserving referential equality in this case would require additional path tracking and ordering during deserialization, which adds significant complexity. For now, I introduced an isSamePath marker to avoid incorrect behavior and documented this as a limitation.

I’m not sure if this feature fits the scope of the main library. If not, would it be okay for me to maintain and use this as a fork in a public project where this recursive behavior is needed?

@ZiadTaha62
Copy link
Copy Markdown
Author

ZiadTaha62 commented Mar 24, 2026

Also this is small function that can make path in meta.values more compact:

/**
 * Handle inner annotations to make it more compact while remain human readable
 * It takes `innerAnnotations` var from outside to mutate by adding recursive annotations to it
 *
 * Behavior:
 *  - If new annotation is `undefined` just return with no modification
 *  - If new annotation is Array (just a node) append it to `innerAnnotations` under passed `key`
 *  - If new annotation is Object:
 *    - If only one key is present append it to `innerAnnotations` under passed `key.annotationKey` (Make it compact)
 *    - If multiple keys are present appent it to `innerAnnotations` under passed `key`
 */
function handleInnerAnnotations(
  innerAnnotations: Record<string, MinimisedTree<TypeAnnotation>>,
  index: string,
  annotation: MinimisedTree<TypeAnnotation>
) {
  if (!annotation) return;
  const key = escapeKey(index);

  if (isArray(annotation)) {
    innerAnnotations[key] = annotation;
    return;
  }

  const innerKeys = Object.keys(annotation);
  if (innerKeys.length === 1) {
    const innerKey = innerKeys[0];
    innerAnnotations[key + '.' + innerKey] = annotation[innerKey];
  } else {
    innerAnnotations[key] = annotation;
  }
}

it's meant to replace:

    if (isArray(recursiveResult.annotations)) {
      innerAnnotations[escapeKey(index)] = recursiveResult.annotations;
    } else if (isPlainObject(recursiveResult.annotations)) {
      forEach(recursiveResult.annotations, (tree, key) => {
        innerAnnotations[escapeKey(index) + '.' + key] = tree;
      });
    }

However it requires this type change:

type Tree<T> = InnerNode<T> | Leaf<T>;
type Leaf<T> = [T];
type InnerNode<T> = [T, MinimisedTree<T>];

export type MinimisedTree<T> =
  | Tree<T>
  | { [k: string]: MinimisedTree<T> }
  | undefined;

and this update in traverse function in plainer.ts:

const [nodeValue, children] = tree;
  if (children) {
    if (isArray(children)) {
      traverse(children, walker, version, origin);
    } else {
      forEach(children, (child, key) => {
        traverse(child, walker, version, [
          ...origin,
          ...parsePath(key, legacyPaths),
        ]);
      });
    }
  }

when used meta.values will be compacted from:

{
  "values": {
    "user.profile.createdAt": ["Date"],
    "user.profile.updatedAt": ["Date"],
    "user.settings.theme": [["class", "Theme"]],
    "user.settings.language": [["class", "Lan"]],
    "user.settings.notifications.email": [["custom", "Email"]],
    "user.settings.notifications.location": [["custom", "Location"]]
  }
}

to:

{
  "values": {
    "user": {
      "profile": {
        "createdAt": ["Date"],
        "updatedAt": ["Date"]
      },
      "settings": {
        "theme": [["class", "Theme"]],
        "language": [["class", "Lan"]],
        "notifications": {
          "email": [["custom", "Email"]],
          "location": [["custom", "Location"]]
        }
      }
    }
  }
}

Comment on lines +64 to +72

const equalityGroup = equalityGroups.get(
stringifyPathWithDepth([childPath, 0])
);

if (equalityGroup) {
if (equalityGroup.resolved) return;
equalityGroup.resolved = true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Intermediate-path equality resolution silently drops child annotations

When a path key in the values tree has multiple segments (e.g. "a.b.date"), the intermediate loop checks whether any intermediate path segment ("a", "a.b") belongs to an equality group. If it does and resolved is false, the group is marked resolved = true and traversal continues. This is correct.

However, the return inside the for loop:

if (equalityGroup.resolved) return;   // exits only the forEach callback

exits only the current forEach callback, not the outer function. This means that when a second key like "c.d.date" is processed — where c.d is the duplicate of a.b — the callback returns early, which is the intended skip. But there is a subtle ordering hazard:

If two different keys in the tree share an intermediate segment that belongs to an equality group, the first processed key marks the group resolved, and the second key skips its entire subtree. Any child annotations under the second key are never applied.

Example: imagine keys "shared.x" and "shared.y" where "shared" is in an equality group. After processing "shared.x", the group is marked resolved. When "shared.y" is iterated in forEach, the intermediate check for "shared" finds resolved = true and returns early — the ["shared", "y"] annotation is never applied. This edge case should be covered by a targeted test before merging.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plainer.ts
Line: 64-72

Comment:
**Intermediate-path equality resolution silently drops child annotations**

When a path key in the values tree has multiple segments (e.g. `"a.b.date"`), the intermediate loop checks whether any intermediate path segment (`"a"`, `"a.b"`) belongs to an equality group. If it does and `resolved` is `false`, the group is marked `resolved = true` and traversal continues. This is correct.

However, the `return` inside the `for` loop:
```ts
if (equalityGroup.resolved) return;   // exits only the forEach callback
```
exits only the **current `forEach` callback**, not the outer function. This means that when a second key like `"c.d.date"` is processed — where `c.d` is the duplicate of `a.b` — the callback returns early, which is the intended skip. But there is a subtle ordering hazard:

If two **different** keys in the tree share an intermediate segment that belongs to an equality group, the **first** processed key marks the group resolved, and the **second** key skips its entire subtree. Any child annotations under the second key are never applied.

Example: imagine keys `"shared.x"` and `"shared.y"` where `"shared"` is in an equality group. After processing `"shared.x"`, the group is marked resolved. When `"shared.y"` is iterated in `forEach`, the intermediate check for `"shared"` finds `resolved = true` and returns early — the `["shared", "y"]` annotation is never applied. This edge case should be covered by a targeted test before merging.

How can I resolve this? If you propose a fix, please make it concise.

src/plainer.ts Outdated
Comment on lines 248 to 255
// Apply other referential equality
for (const path of representatives) {
const equalityGroup = equalityGroups.get(path)!;
setReferentialEqualityFn(
equalityGroup.representative,
equalityGroup.duplicates
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Pre-application of referential equalities uses stale (pre-transform) values

Step 1 of applyMeta eagerly copies representative values to all their duplicates before any type-annotation is applied. For a representative path that has no entry in the values tree, step 2's setValueAnnotationsFn never fires for that path, so the duplicates are permanently set to the raw serialized form rather than the true deserialized value.

Consider adding a test that verifies a dedupe: true scenario where two paths share the same referential-equality group but only one of them carries a values annotation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plainer.ts
Line: 248-255

Comment:
**Pre-application of referential equalities uses stale (pre-transform) values**

Step 1 of `applyMeta` eagerly copies representative values to all their duplicates before any type-annotation is applied. For a representative path that has no entry in the `values` tree, step 2's `setValueAnnotationsFn` never fires for that path, so the duplicates are permanently set to the raw serialized form rather than the true deserialized value.

Consider adding a test that verifies a `dedupe: true` scenario where two paths share the same referential-equality group but only one of them carries a values annotation.

How can I resolve this? If you propose a fix, please make it concise.

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