Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .specs/2026-07-28--511-cycle-safe-reused-instance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Feature: Cycle-safe, identity-aware reused-instance sync

| Field | Value |
| ---------------- | ------------------------------------------------------- |
| **Status** | completed |
| **Owner** | @paul |
| **Issue** | [#511](https://github.com/Weaverse/weaverse/issues/511) |
| **Branch** | `fix/511-cycle-safe-reused-instance` |
| **Created** | 2026-07-28 |
| **Last Updated** | 2026-07-28 |

## Original Prompt

> Analyze and fix https://github.com/Weaverse/weaverse/issues/511 so reused
> Hydrogen instances safely adopt deferred loader data during same-URL
> revalidation without render-time crashes or stale Promise-backed context.

## Summary

`syncReusedInstance()` compared loader payloads with bare `JSON.stringify()`
during render. `dataContext` carries live route loader data, so deferred values
are still unresolved `Promise`s: React 19 development builds attach an
enumerable, self-referencing `_debugInfo` to them and stringifying throws
`TypeError: Converting circular structure to JSON`. In production the same call
silently returns `{}` for every promise, so a *fresh* deferred value compares
equal to the previous one and the reused instance keeps serving stale context.
This spec replaces that comparison with `isSameLoaderPayload()`, a structural
comparison that treats promises/thenables and other opaque objects as atomic
identity-compared values and handles cyclic graphs coinductively.
80 changes: 80 additions & 0 deletions .specs/2026-07-28--511-cycle-safe-reused-instance/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Plan: cycle-safe, identity-aware reused-instance sync

## Problem

`syncReusedInstance()` runs during render on the instance-reuse branch
(same-URL revalidation in live/preview mode) and compared payloads with:

```ts
JSON.stringify(weaverse.dataContext ?? null) !==
JSON.stringify(params.dataContext ?? null)
```

`createWeaverseDataContext()` copies each route match's **raw** `data` into
`dataContext`, so deferred loader values are still unresolved `Promise`s.

Two distinct defects follow from one root cause — *JSON serialization is the
wrong equality model for a live, unserialized object graph*:

1. **Crash (development).** React 19 attaches an enumerable, self-referencing
`_debugInfo` to deferred promises. `JSON.stringify` throws
`TypeError: Converting circular structure to JSON` at render time.
2. **Stale context (production).** Without that metadata, every promise
stringifies to `{}`. A fresh deferred value therefore compares *equal* to
the previous render's promise, `contextChanged` stays `false`, and the
reused instance keeps serving a settled/abandoned promise.

The issue's proposed `'[[promise]]'` token fixes only (1). Verified: two
distinct promises produce identical output under both bare JSON and the
token form, so (2) survives. A blanket `try`/`catch` is also rejected — it
would treat every render as changed and call `triggerUpdate()` in a loop.

## Approach

Add `isSameLoaderPayload(left, right)` and use it for both comparisons.
`syncReusedInstance`'s control flow (assignment order, item notification,
`triggerUpdate`) is deliberately unchanged — only the equality test is swapped.

Comparison semantics, chosen deliberately:

| Case | Behavior | Why |
| --- | --- | --- |
| Promises / thenables | atomic, identity-compared | fresh promise = change; debug metadata never traversed |
| Opaque objects (`Map`, `Set`, `URL`, class instances, cross-realm promises) | atomic, identity-compared | JSON flattens all to `{}`, collapsing distinct async values |
| `toJSON()` bearers (`Date`) | compared via projection | matches wire behavior |
| Cycles | coinductive (assume equal on re-entry) | equal cycles equal, differing leaf still a change, never throws |
| `undefined`/function/symbol | omitted in objects, `null` in arrays | matches `JSON.stringify` |
| NaN / ±Infinity | normalized to `null` | matches `JSON.stringify` |
| Key order | irrelevant | removes the old "false mismatch costs a re-render" caveat |

Thenables are duck-typed (`typeof value.then === 'function'`) rather than
`instanceof Promise` so cross-realm promises stay atomic.

Cycle tracking is scoped to the **active comparison path** (a flat
`[a0,b0,a1,b1,...]` array), not every visited pair: memory stays proportional
to graph depth and the common all-equal walk allocates one array. Memoizing
equal pairs instead measured ~2x slower on a realistic 400-item page, which is
the case that runs on every render. No new dependency.

## Files touched

| File | Change |
| --- | --- |
| `packages/hydrogen/src/utils/is-same-loader-payload.ts` | new — the comparison + rationale |
| `packages/hydrogen/src/utils/sync-reused-instance.ts` | use it for both comparisons; refresh doc comment |
| `packages/hydrogen/__tests__/is-same-loader-payload.test.ts` | new — 22 semantic tests |
| `packages/hydrogen/__tests__/sync-reused-instance.test.ts` | +8 tests: circular promise, fresh identity, repeat sync, cycles, null/undefined, no double-notify |

Internal module only: not exported from `src/utils/index.ts` or `src/index.ts`,
so the public API surface is unchanged (`api:check` passes clean).

## Verification

- RED first: 6 failed / 4 passed with the exact `Converting circular structure
to JSON` trace at `sync-reused-instance.ts:31`.
- GREEN: 34 focused tests; `@weaverse/hydrogen` 203 tests; repo test, typecheck,
Biome, build, and `api:check` all clean.
- Mutation-tested: 8 mutants against the final implementation, all killed
(one surviving mutant exposed dead code, which was removed).
- Differential parity harness vs `JSON.stringify` across 12 edge cases; the only
intended divergence is that cyclic input does not throw.
215 changes: 215 additions & 0 deletions packages/hydrogen/__tests__/is-same-loader-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import { isSameLoaderPayload } from '../src/utils/is-same-loader-payload'

/** Self-referencing route data: `root.shop.parent === root`. */
function makeSelfCycle(shopName: string) {
let shop: Record<string, unknown> = { name: shopName }
let root: Record<string, unknown> = { shop }
shop.parent = root
return root
}

describe('isSameLoaderPayload', () => {
it('should_report_equal_when_plain_payloads_match_with_different_key_order', () => {
let left = { id: 'page-1', items: [{ id: 'item-1', index: 0 }] }
let right = { items: [{ index: 0, id: 'item-1' }], id: 'page-1' }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(true)
})

it('should_report_changed_when_a_nested_leaf_differs', () => {
let left = { page: { items: [{ id: 'item-1' }] } }
let right = { page: { items: [{ id: 'item-2' }] } }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(false)
})

it('should_report_changed_when_array_lengths_differ', () => {
let left = { items: [1, 2] }
let right = { items: [1, 2, 3] }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(false)
})

it('should_report_equal_when_arrays_hold_json_omitted_holes_in_the_same_slots', () => {
// JSON.stringify serializes both as `[null]`.
let result = isSameLoaderPayload([undefined], [() => undefined])

expect(result).toBe(true)
})

it('should_report_equal_when_only_json_omitted_properties_differ', () => {
let left = { name: 'Weaverse', onSelect: () => undefined }
let right = { name: 'Weaverse', missing: undefined }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(true)
})

it('should_report_changed_when_the_right_side_adds_a_json_visible_key', () => {
let left = { name: 'Weaverse' }
let right = { name: 'Weaverse', locale: 'en' }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(false)
})

it('should_report_equal_when_a_non_finite_number_faces_the_null_it_serializes_to', () => {
// A revalidated wire payload carries `null` where the in-memory value was
// NaN/Infinity; treating that as a change would re-render every render.
let result = isSameLoaderPayload({ ratio: Number.NaN }, { ratio: null })

expect(result).toBe(true)
})

it('should_report_equal_when_dates_carry_the_same_instant', () => {
let left = { publishedAt: new Date('2026-07-28T00:00:00.000Z') }
let right = { publishedAt: new Date('2026-07-28T00:00:00.000Z') }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(true)
})

it('should_report_changed_when_a_date_moves_to_another_instant', () => {
let left = { publishedAt: new Date('2026-07-28T00:00:00.000Z') }
let right = { publishedAt: new Date('2026-07-29T00:00:00.000Z') }

let result = isSameLoaderPayload(left, right)

expect(result).toBe(false)
})

it('should_report_equal_when_the_same_promise_identity_is_reused', () => {
let deferred = Promise.resolve({ colors: [] })

let result = isSameLoaderPayload(
{ root: { swatchesConfigs: deferred } },
{ root: { swatchesConfigs: deferred } }
)

expect(result).toBe(true)
})

it('should_report_changed_when_a_distinct_promise_resolves_to_equal_data', () => {
// Both stringify as `{}` — identity is the only signal that the loader
// handed us a fresh deferred value.
let result = isSameLoaderPayload(
{ root: { swatchesConfigs: Promise.resolve({ colors: [] }) } },
{ root: { swatchesConfigs: Promise.resolve({ colors: [] }) } }
)

expect(result).toBe(false)
})

it('should_report_changed_when_distinct_thenables_stand_in_for_promises', () => {
// Cross-realm promises and hand-rolled thenables fail `instanceof Promise`;
// duck-typing keeps them atomic instead of collapsing them to `{}`.
// biome-ignore-start lint/suspicious/noThenProperty: exercising the thenable detection path requires a literal `then`
let result = isSameLoaderPayload(
{ deferred: { then: () => undefined } },
{ deferred: { then: () => undefined } }
)
// biome-ignore-end lint/suspicious/noThenProperty: see above

expect(result).toBe(false)
})

it('should_report_changed_when_distinct_opaque_objects_hold_equal_entries', () => {
let result = isSameLoaderPayload(
{ index: new Map([['a', 1]]) },
{ index: new Map([['a', 1]]) }
)

expect(result).toBe(false)
})

it('should_not_descend_into_react_debug_metadata_on_a_reused_promise', () => {
let deferred = Promise.resolve({ colors: [] })
let debugEntry: Record<string, unknown> = { awaited: {} }
;(debugEntry.awaited as Record<string, unknown>).value = deferred
;(deferred as unknown as Record<string, unknown>)._debugInfo = [debugEntry]

let result = isSameLoaderPayload({ root: deferred }, { root: deferred })

expect(result).toBe(true)
})

it('should_report_equal_when_self_cycles_have_equal_leaves', () => {
let result = isSameLoaderPayload(
makeSelfCycle('Weaverse'),
makeSelfCycle('Weaverse')
)

expect(result).toBe(true)
})

it('should_report_changed_when_self_cycles_differ_at_a_leaf', () => {
let result = isSameLoaderPayload(
makeSelfCycle('Weaverse'),
makeSelfCycle('Pilot')
)

expect(result).toBe(false)
})

it('should_report_equal_when_a_self_cycle_is_bisimilar_to_a_mutual_cycle', () => {
let single: Record<string, unknown> = {}
single.next = single
let first: Record<string, unknown> = {}
let second: Record<string, unknown> = { next: first }
first.next = second

let result = isSameLoaderPayload(single, first)

expect(result).toBe(true)
})

it('should_report_changed_when_a_cycle_faces_a_finite_chain', () => {
let cyclic: Record<string, unknown> = {}
cyclic.next = cyclic
let finite = { next: { next: null } }

let result = isSameLoaderPayload(cyclic, finite)

expect(result).toBe(false)
})

it('should_report_equal_when_repeated_aliases_face_distinct_equal_objects', () => {
// Aliasing is not observable through JSON, so it is not a change signal.
let shared = { name: 'Weaverse' }

let result = isSameLoaderPayload(
{ a: shared, b: shared },
{ a: { name: 'Weaverse' }, b: { name: 'Weaverse' } }
)

expect(result).toBe(true)
})

it('should_report_changed_when_an_array_faces_a_plain_object', () => {
let result = isSameLoaderPayload({ items: [] }, { items: {} })

expect(result).toBe(false)
})

it('should_report_equal_when_both_sides_are_null', () => {
let result = isSameLoaderPayload(null, null)

expect(result).toBe(true)
})

it('should_report_changed_when_null_faces_an_empty_object', () => {
let result = isSameLoaderPayload(null, {})

expect(result).toBe(false)
})
})
Loading
Loading