Skip to content
Merged
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
16 changes: 16 additions & 0 deletions packages/bindx/src/handles/BaseHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,19 @@ export abstract class EntityRelatedHandle extends BaseHandle {
return snapshot?.serverData as Record<string, unknown> | undefined
}
}

/**
* Shallow comparison of embedded data keys against existing snapshot data.
* Returns true if all keys in embedded data match the snapshot.
*/
export function embeddedDataMatchesSnapshot(
embedded: Record<string, unknown>,
snapshot: Record<string, unknown>,
): boolean {
for (const key of Object.keys(embedded)) {
if (embedded[key] !== snapshot[key]) {
return false
}
}
return true
}
53 changes: 34 additions & 19 deletions packages/bindx/src/handles/HasManyListHandle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EntityRelatedHandle } from './BaseHandle.js'
import { EntityRelatedHandle, embeddedDataMatchesSnapshot } from './BaseHandle.js'
import { EntityHandle } from './EntityHandle.js'
import type { ActionDispatcher } from '../core/ActionDispatcher.js'
import type { SnapshotStore } from '../store/SnapshotStore.js'
Expand Down Expand Up @@ -124,23 +124,36 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
const listData = this.extractItems(rawData)
if (!listData) return []

// Ensure snapshots exist for embedded items
this.ensureItemSnapshots(listData)
const fieldKey = this.alias ?? this.fieldName

// Extract server IDs from embedded data
const serverIds = listData
.map((item) => item['id'] as string | undefined)
.filter((id): id is string => id !== undefined)
// Only propagate embedded data when the parent's reference changed (re-fetch).
// Same reference means the embedded data is stale and must not overwrite
// child state that may have been updated by a local commit.
if (this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, fieldKey, rawData)) {
this.ensureItemSnapshots(listData)

// Ensure has-many state exists with proper server IDs
// This is needed so that connect/disconnect operations work correctly
this.store.getOrCreateHasMany(
this.entityType,
this.entityId,
this.fieldName,
serverIds,
this.alias,
)
const serverIds = listData
.map((item) => item['id'] as string | undefined)
.filter((id): id is string => id !== undefined)

this.store.getOrCreateHasMany(
this.entityType,
this.entityId,
this.fieldName,
serverIds,
this.alias,
)
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, fieldKey, rawData)
} else {
// Ensure has-many state exists (without updating serverIds)
this.store.getOrCreateHasMany(
this.entityType,
this.entityId,
this.fieldName,
undefined,
this.alias,
)
}

// Use ordered IDs from store (handles removals, connections, and ordering)
const orderedIds = this.store.getHasManyOrderedIds(
Expand All @@ -155,6 +168,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt

/**
* Ensures snapshots exist for embedded has-many items.
* Only called when parent's embedded data has changed (re-fetch detected).
*/
private ensureItemSnapshots(listData: Array<Record<string, unknown>>): void {
for (const itemData of listData) {
Expand All @@ -165,12 +179,13 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
// This needs to happen even if the snapshot already exists
this.store.registerParentChild(this.entityType, this.entityId, this.itemType, itemId)

// Skip if snapshot already exists
if (this.store.hasEntity(this.itemType, itemId)) {
// Optimization: skip if item data matches existing snapshot
const existing = this.store.getEntitySnapshot(this.itemType, itemId)
if (existing?.serverData && embeddedDataMatchesSnapshot(itemData, existing.serverData as Record<string, unknown>)) {
continue
}

// Create snapshot from embedded data
// Create or update snapshot from embedded data
// Skip notification to avoid triggering React state updates during render
this.store.setEntityData(
this.itemType,
Expand Down
27 changes: 20 additions & 7 deletions packages/bindx/src/handles/HasOneHandle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EntityRelatedHandle } from './BaseHandle.js'
import { EntityRelatedHandle, embeddedDataMatchesSnapshot } from './BaseHandle.js'
import type { ActionDispatcher } from '../core/ActionDispatcher.js'
import type { SnapshotStore } from '../store/SnapshotStore.js'
import type { SchemaRegistry } from '../schema/SchemaRegistry.js'
Expand Down Expand Up @@ -283,11 +283,6 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
// This needs to happen even if the snapshot already exists
this.store.registerParentChild(this.entityType, this.entityId, this.targetType, id)

// Check if snapshot already exists
if (this.store.hasEntity(this.targetType, id)) {
return
}

// Get embedded data from parent entity
const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
if (!parentSnapshot?.data) {
Expand All @@ -308,7 +303,24 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
return
}

// Create snapshot from embedded data
// Skip if parent's embedded data reference hasn't changed since last propagation.
// A new reference means the parent was re-fetched from the server.
// Same reference means the embedded data is stale and must not overwrite
// child state that may have been updated by a local commit.
if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) {
return
}

// Skip if embedded data values match existing serverData — avoids overwriting
// unpersisted local mutations when a re-fetch returns the same server data
// (e.g. polling). A new reference with identical values means no actual change.
const existing = this.store.getEntitySnapshot(this.targetType, id)
if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record<string, unknown>, existing.serverData as Record<string, unknown>)) {
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData)
return
}

// Create or update snapshot from embedded data
// Skip notification to avoid triggering React state updates during render
this.store.setEntityData(
this.targetType,
Expand All @@ -317,6 +329,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
true, // isServerData
true, // skipNotify - called during render, data already exists embedded in parent
)
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData)
}

/**
Expand Down
21 changes: 20 additions & 1 deletion packages/bindx/src/store/RelationStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { HasOneRelationState } from '../handles/types.js'
import type { EntitySnapshot } from './snapshots.js'

function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false
for (const item of a) {
if (!b.has(item)) return false
}
return true
}

/**
* Removal type for has-many items
*/
Expand Down Expand Up @@ -150,7 +158,8 @@ export class RelationStore {
* Gets or creates has-many list state.
*/
getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
if (!this.hasManyStates.has(key)) {
const existing = this.hasManyStates.get(key)
if (!existing) {
this.hasManyStates.set(key, {
serverIds: new Set(serverIds ?? []),
orderedIds: null,
Expand All @@ -159,6 +168,16 @@ export class RelationStore {
createdEntities: new Set(),
version: 0,
})
} else if (serverIds !== undefined) {
const newServerIds = new Set(serverIds)
if (!setsEqual(existing.serverIds, newServerIds)) {
this.hasManyStates.set(key, {
...existing,
serverIds: newServerIds,
orderedIds: null,
version: existing.version + 1,
})
}
}

return this.hasManyStates.get(key)!
Expand Down
56 changes: 55 additions & 1 deletion packages/bindx/src/store/SnapshotStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export class SnapshotStore implements SnapshotVersionBumper {
private readonly touched = new TouchedStore()
private readonly dirtyTracker: DirtyTracker

/**
* Tracks the last embedded data reference propagated from parent to child.
* Used to detect whether the parent was re-fetched (new reference) vs. stale
* embedded data that should not overwrite committed child state.
* Keyed by "parentType:parentId:fieldName".
*/
private readonly lastPropagatedData = new Map<string, unknown>()

constructor() {
this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations)
}
Expand Down Expand Up @@ -98,6 +106,49 @@ export class SnapshotStore implements SnapshotVersionBumper {
}
}

// ==================== Embedded Data Propagation Tracking ====================

/**
* Returns true if the embedded data reference differs from what was last propagated.
* Uses reference identity — a new reference means the parent was re-fetched.
*/
hasEmbeddedDataChanged(parentType: string, parentId: string, fieldName: string, currentData: unknown): boolean {
const key = this.getRelationKey(parentType, parentId, fieldName)
return this.lastPropagatedData.get(key) !== currentData
}

/**
* Records the embedded data reference that was propagated to the child.
*/
markEmbeddedDataPropagated(parentType: string, parentId: string, fieldName: string, data: unknown): void {
const key = this.getRelationKey(parentType, parentId, fieldName)
this.lastPropagatedData.set(key, data)
}

/**
* Removes all propagation tracking entries for a given parent entity.
*/
clearPropagatedDataForEntity(parentType: string, parentId: string): void {
const prefix = `${parentType}:${this.resolveId(parentType, parentId)}:`
for (const key of this.lastPropagatedData.keys()) {
if (key.startsWith(prefix)) {
this.lastPropagatedData.delete(key)
}
}
}

/**
* Rekeys propagation tracking entries when a temp ID is replaced by a persisted ID.
*/
private rekeyPropagatedData(oldPrefix: string, newPrefix: string): void {
for (const [key, value] of this.lastPropagatedData) {
if (key.startsWith(oldPrefix)) {
this.lastPropagatedData.delete(key)
this.lastPropagatedData.set(newPrefix + key.slice(oldPrefix.length), value)
}
}
}

// ==================== Entity Snapshots ====================

getEntitySnapshot<T extends object>(entityType: string, id: string): EntitySnapshot<T> | undefined {
Expand Down Expand Up @@ -172,6 +223,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
const key = this.getEntityKey(entityType, id)
this.entitySnapshots.remove(key)
this.meta.clearLoadState(key)
this.clearPropagatedDataForEntity(entityType, id)
this.notifyEntitySubscribers(key)
}

Expand Down Expand Up @@ -261,9 +313,10 @@ export class SnapshotStore implements SnapshotVersionBumper {
// Replace tempId with persistedId in all relation/hasMany VALUE references
this.relations.replaceEntityId(tempId, persistedId)

// Rekey errors and touched state
// Rekey errors, touched state, and propagation tracking
this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
this.touched.rekey(oldKeyPrefix, newKeyPrefix)
this.rekeyPropagatedData(oldKeyPrefix, newKeyPrefix)

// Notify on the NEW key so React picks up the change
this.notifyEntitySubscribers(newKey)
Expand Down Expand Up @@ -817,6 +870,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
this.relations.clear()
this.errors.clear()
this.touched.clear()
this.lastPropagatedData.clear()

this.subscriptions.notify()
}
Expand Down
Loading