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
9 changes: 4 additions & 5 deletions packages/bindx-react/src/hooks/useEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,11 +333,10 @@ export function useEntity(
[rawHandle],
)

useEffect(() => {
return () => {
rawHandle.dispose()
}
}, [rawHandle])
// `snapshot` is a useMemo dep, so `rawHandle` is recreated on every entity data change (to give
// memoized children a fresh reference). Superseded handles need no cleanup: EntityHandle is a
// stateless live view that owns no resources (see BaseHandle), so it is reclaimed by GC once
// unreferenced and a handle a consumer still holds stays usable for late reads/writes.

// --- Persist & reset callbacks ---
const persist = useCallback(async () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/bindx-react/src/jsx/components/Entity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,12 @@ function EntityHandleRenderer({
[rawHandle],
)

useEffect(() => () => { rawHandle.dispose() }, [rawHandle])
// `version` is a useMemo dep, so `rawHandle` is recreated on every entity data change (to give
// memoized children a fresh reference). Superseded handles need no cleanup: EntityHandle and its
// child Field/HasMany handles are stateless live views that own no resources (see BaseHandle),
// so they are reclaimed by GC once unreferenced — and a consumer that still holds an accessor
// from an earlier render (e.g. the Slate block editor dispatching `setValue` from an async
// onChange after the version bumped) can keep using it.

const result = children(handle as EntityAccessor<unknown>)
return <>{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': entityId })}</>
Expand Down
126 changes: 0 additions & 126 deletions packages/bindx/src/core/Disposable.ts

This file was deleted.

39 changes: 9 additions & 30 deletions packages/bindx/src/handles/BaseHandle.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import type { Disposable, DisposableGroup } from '../core/Disposable.js'
import type { ActionDispatcher } from '../core/ActionDispatcher.js'
import type { SnapshotStore } from '../store/SnapshotStore.js'

/**
* Base class for all handles.
* Provides common functionality for subscription and disposal.
*
* Handles are stable objects that:
* Handles are stateless live views over the store:
* - Have stable identity (same instance across renders)
* - Provide subscribe/getSnapshot for useSyncExternalStore
* - Provide subscribe/getVersion for useSyncExternalStore
* - Dispatch actions for mutations
*
* They own no resources — the only subscriptions a handle creates are returned
* from `subscribe()` and owned by `useSyncExternalStore`, not stored on the
* handle. There is therefore intentionally no dispose lifecycle: a handle is
* reclaimed by GC once unreferenced, and a superseded handle (one replaced by a
* fresh instance on a data change) stays fully usable for late reads/writes.
*/
export abstract class BaseHandle implements Disposable {
protected _disposed = false

export abstract class BaseHandle {
constructor(
protected readonly store: SnapshotStore,
protected readonly dispatcher: ActionDispatcher,
Expand All @@ -28,29 +30,6 @@ export abstract class BaseHandle implements Disposable {
* Get current version for change detection.
*/
abstract getVersion(): number

/**
* Dispose resources (unsubscribe, cleanup).
*/
dispose(): void {
this._disposed = true
}

/**
* Check if the handle has been disposed.
*/
get isDisposed(): boolean {
return this._disposed
}

/**
* Throws if the handle has been disposed.
*/
protected assertNotDisposed(): void {
if (this._disposed) {
throw new Error('Handle has been disposed')
}
}
}

/**
Expand Down
27 changes: 2 additions & 25 deletions packages/bindx/src/handles/EntityHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@ import { createHandleProxy } from './proxyFactory.js'
import type { SelectionMeta } from '../selection/types.js'
import { UnfetchedFieldError } from '../errors/UnfetchedFieldError.js'

/** Minimal internal interface for cached relation handles that need reset/dispose.
* At runtime, the proxied handles satisfy this through delegation, even if the public type doesn't expose dispose(). */
/** Minimal internal interface for cached relation handles that need reset.
* At runtime, the proxied handles satisfy this through delegation. */
interface RelationHandleRaw {
reset(): void
dispose(): void
}

interface CachedFieldHandle {
Expand Down Expand Up @@ -428,7 +427,6 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
* Also resets all relation states (hasOne, hasMany).
*/
reset(): void {
this.assertNotDisposed()
this.dispatcher.dispatch(resetEntity(this.entityType, this.entityId))

// Reset all cached relation handles
Expand All @@ -441,27 +439,9 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
* Commits changes (serverData = data).
*/
commit(): void {
this.assertNotDisposed()
this.dispatcher.dispatch(commitEntity(this.entityType, this.entityId))
}

/**
* Disposes the handle and all cached child handles.
*/
override dispose(): void {
super.dispose()

for (const { raw } of this.fieldHandleCache.values()) {
raw.dispose()
}
this.fieldHandleCache.clear()

for (const { raw } of this.relationHandleCache.values()) {
raw.dispose()
}
this.relationHandleCache.clear()
}

/**
* Creates a proxy for field access.
* Returns appropriate handle type based on schema field definition:
Expand Down Expand Up @@ -541,7 +521,6 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
* Adds a client-side error to this entity.
*/
addError(error: ErrorInput): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
addEntityError(this.entityType, this.entityId, createClientError(error)),
)
Expand All @@ -551,7 +530,6 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
* Clears entity-level errors.
*/
clearErrors(): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
clearEntityErrors(this.entityType, this.entityId),
)
Expand All @@ -561,7 +539,6 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
* Clears all errors (entity-level, fields, and relations).
*/
clearAllErrors(): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
clearAllErrorsAction(this.entityType, this.entityId),
)
Expand Down
4 changes: 0 additions & 4 deletions packages/bindx/src/handles/FieldHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
* Call this on blur or other interaction events.
*/
touch(): void {
this.assertNotDisposed()
this.store.setFieldTouched(this.entityType, this.entityId, this.fieldName, true)
}

Expand All @@ -133,7 +132,6 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
* Also clears non-sticky client errors.
*/
setValue(value: T | null): void {
this.assertNotDisposed()
// Clear non-sticky errors when value changes
this.store.clearNonStickyFieldErrors(this.entityType, this.entityId, this.fieldName)
this.dispatcher.dispatch(
Expand All @@ -159,7 +157,6 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
* Adds a client-side error to this field.
*/
addError(error: ErrorInput): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
addFieldError(this.entityType, this.entityId, this.fieldName, createClientError(error)),
)
Expand All @@ -169,7 +166,6 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
* Clears all errors from this field.
*/
clearErrors(): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
clearFieldErrors(this.entityType, this.entityId, this.fieldName),
)
Expand Down
Loading