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
26 changes: 26 additions & 0 deletions .changeset/dynamic-handle-remapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@inkandswitch/patchwork-providers": minor
"@inkandswitch/patchwork-elements": patch
---

Dynamic handle remapping without remounts: `repo:handle-descriptor` is now a
streaming subscription end-to-end.

- `OverlayRepo.find`/`findWithProgress` keep their descriptor subscription
open instead of taking only the first answer. When a remapper emits a new
descriptor (e.g. a draft overlay re-pointing at a different clone), the live
`OverlayHandle` is re-pointed in place via the new `swapBackingDocHandle`:
forwarded event listeners are re-wired onto the new backing, cached
sub-handles are recursively re-based, and a synthetic `change` event with
`scopeReplaced: true` tells consumers to reconcile from `doc()` rather than
apply patches (the old and new backings may be divergent forks). One-shot
providers that answer exactly once keep working unchanged.
- `forwardingProxy` accepts a backing getter so the proxy identity consumers
hold stays fixed while the backing changes underneath.
- `OverlayRepo` gained `dispose()`, which tears down the live descriptor
subscriptions; `<patchwork-view>` calls it when the element disconnects
(the overlay repo survives attribute-driven teardowns/re-syncs).
- `OverlayHandle.emit` isolates listener errors (logged, not rethrown) so one
consumer that cannot handle an event does not starve the listeners behind
it, and skips listeners unsubscribed during the same emit so a torn-down
consumer is never invoked with an event it already handed off.
7 changes: 6 additions & 1 deletion core/elements/src/patchwork-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,12 @@ export function registerPatchworkViewElement(
}

disconnectedCallback() {
void this.#teardown();
// The overlay repo survives attribute-driven re-syncs; only a real
// disconnect releases it. Detach now so a re-connect gets a fresh
// shim, then dispose once the teardown has run.
const overlayRepo = this.#overlayRepo;
this.#overlayRepo = null;
void this.#teardown().then(() => overlayRepo?.dispose());
}

connectedMoveCallback() {
Expand Down
14 changes: 10 additions & 4 deletions packages/providers/core/src/forwarding-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,32 @@
* `overrides` itself and transparently forwards every other access to
* `backing`.
*
* `backing` may also be a getter, re-evaluated on every access, so the owner
* can swap the backing under a stable proxy identity (see
* `OverlayHandle.swapBackingDocHandle`).
*
* Both sides are read with the matching receiver and functions are bound to
* their owner: owned members run against `overrides` (so its private `#fields`
* keep working) and forwarded members run against `backing` (so the borrowed
* keep working) and forwarded members run against the backing (so the borrowed
* method gets the right `this`). This lets the overlay classes spell out only
* the handful of members whose behavior differs and inherit the rest of the
* large, evolving `Repo` / `DocHandle` surface for free.
*/
export function forwardingProxy<T>(
overrides: object,
backing: object,
backing: object | (() => object),
owned: ReadonlySet<PropertyKey>
): T {
const currentBacking =
typeof backing === "function" ? (backing as () => object) : () => backing;
return new Proxy(overrides, {
get(target, prop) {
const source = owned.has(prop) ? target : backing;
const source = owned.has(prop) ? target : currentBacking();
const value = Reflect.get(source, prop, source);
return typeof value === "function" ? value.bind(source) : value;
},
has(target, prop) {
return owned.has(prop) || prop in backing || prop in target;
return owned.has(prop) || prop in currentBacking() || prop in target;
},
}) as T;
}
131 changes: 102 additions & 29 deletions packages/providers/core/src/overlay-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import { forwardingProxy } from "./forwarding-proxy.js";
type Listener = (...args: unknown[]) => void;

// The only members whose behavior an overlay handle changes: the presented
// identity, the identity-preserving `sub` (so sub-handle urls stay in the
// presented space), the argument-unwrapping handle comparisons
// identity, the backing accessors (`backingHandle`/`swapBackingDocHandle`),
// the identity-preserving `sub` (sub-handle urls stay in the presented space),
// the argument-unwrapping handle comparisons
// (`merge`/`overlaps`/`contains`/`isChildOf`/`equals`), and the re-stamping
// EventEmitter surface. Everything else (doc/change/heads/ref/view/diff/...)
// forwards to the backing handle.
const OVERLAY_HANDLE_OWNED: ReadonlySet<PropertyKey> = new Set<PropertyKey>([
"url",
"documentId",
"backingHandle",
"swapBackingDocHandle",
"sub",
"merge",
"overlaps",
Expand All @@ -41,25 +43,35 @@ export type OverlayHandleOpts<T> = {
backing: DocHandle<T>;
};

type SubCacheEntry = {
wrapped: OverlayHandle<unknown>;
/** Original `sub(...)` args, replayed on a backing swap. */
segments: unknown[];
};

/**
* A url-hiding proxy around a fixed backing `DocHandle`. `url`/`documentId`
* A url-hiding proxy around a swappable backing `DocHandle`. `url`/`documentId`
* always report the *presented* url; every other operation forwards to the
* backing handle. Event subscriptions are tracked locally and the backing
* handle's events are lazily forwarded with `payload.handle` re-stamped to this
* wrapper, so consumers never observe the backing (clone) handle or its url.
* current backing handle. Event subscriptions are tracked locally and the
* backing handle's events are lazily forwarded with `payload.handle`
* re-stamped to this wrapper, so consumers never observe the backing (clone)
* handle or its url.
*
* Unlike the copy-on-write handle it is modelled on, the backing never swaps:
* remappers clone eagerly and hand back the clone up front, so there is no
* COW, no re-wiring, and no synthetic change nudge.
* Remappers may re-point a live handle at a different backing via
* {@link swapBackingDocHandle}: listeners are re-wired, cached sub-handles
* re-based, and a synthetic `change` with `scopeReplaced: true` tells
* consumers to re-read `doc()` — the backings may be divergent forks with no
* patch stream connecting them.
*/
export class OverlayHandle<T> {
readonly #originalUrl: AutomergeUrl;
readonly #handle: DocHandle<T>;
#handle: DocHandle<T>;
readonly #listeners = new Map<string, Set<Listener>>();
readonly #forwarded = new Map<string, Listener>();
// Sub-handle wrappers keyed by their backing (canonicalised) url, so repeated
// `sub(...)` of the same path return the same wrapper instance.
readonly #subCache = new Map<AutomergeUrl, OverlayHandle<unknown>>();
// Forwarders attached to the current backing; moved over on a swap.
readonly #forwarders = new Map<string, Listener>();
// Sub-handle wrappers keyed by *presented* url (stable across swaps), so
// repeated `sub(...)` of the same path return the same wrapper.
readonly #subCache = new Map<AutomergeUrl, SubCacheEntry>();
// The Proxy returned from the constructor — the object consumers actually
// hold. Re-stamped onto forwarded events so identity stays consistent.
#self: OverlayHandle<T>;
Expand All @@ -69,7 +81,7 @@ export class OverlayHandle<T> {
this.#handle = opts.backing;
this.#self = forwardingProxy<OverlayHandle<T>>(
this,
opts.backing,
() => this.#handle,
OVERLAY_HANDLE_OWNED
);
return this.#self;
Expand All @@ -83,11 +95,55 @@ export class OverlayHandle<T> {
return parseAutomergeUrl(this.#originalUrl).documentId;
}

/** @internal The live handle this wrapper forwards to. */
/** @internal The live handle this wrapper currently forwards to. */
get backingHandle(): DocHandle<T> {
return this.#handle;
}

/**
* @internal Re-point this wrapper and its cached sub-handles at a new
* backing, keeping presented and proxy identity intact. Emits a synthetic
* `change` with `scopeReplaced: true` so consumers reconcile from `doc()`
* instead of applying patches.
*/
swapBackingDocHandle(next: DocHandle<T>): void {
const previous = this.#handle;
if (next === previous) return;
const before = previous.doc();
this.#handle = next;

// Move the forwarders onto the new backing so listeners keep firing.
const emitter = (handle: DocHandle<T>) =>
handle as unknown as {
on(ev: string, fn: Listener): void;
off(ev: string, fn: Listener): void;
};
for (const [ev, forwarder] of this.#forwarders) {
emitter(previous).off(ev, forwarder);
emitter(next).on(ev, forwarder);
}

// Re-base cached sub-handles against the new backing; each child emits
// its own scopeReplaced change.
for (const entry of this.#subCache.values()) {
const nextSub = (
next as unknown as { sub: (...s: unknown[]) => DocHandle<unknown> }
).sub(...entry.segments);
entry.wrapped.swapBackingDocHandle(nextSub);
}

// No patch stream connects the two backings, so signal a wholesale scope
// replacement; consumers reconcile from `doc` instead of patches.
const after = next.doc();
this.emit("change", {
handle: this.#self,
doc: after,
patches: [],
scopeReplaced: true,
patchInfo: { before, after, source: "change" },
});
}

// Handle comparisons read the *other* handle's internals (Automerge rejects
// a foreign wrapper, and `overlaps`/`contains` touch its private `#path`), so
// unwrap an overlay argument down to its backing before delegating.
Expand Down Expand Up @@ -116,22 +172,23 @@ export class OverlayHandle<T> {
// would leak the backing (clone) documentId and break identity for consumers
// that resolve it back through the overlay. We scope `sub` to the backing
// handle (reads/writes still hit the clone) but wrap the result in another
// OverlayHandle whose presented url swaps the documentId back to ours. The
// backing sub-handle is canonicalised by the repo, so caching by its url
// keeps our wrappers referentially stable too.
// OverlayHandle whose presented url swaps the documentId back to ours.
// Cached by presented url (stable across swaps); the segments are kept so
// a swap can replay them against the new backing.
sub(...segments: unknown[]): DocHandle<unknown> {
const backingSub = (
this.#handle as unknown as {
sub: (...s: unknown[]) => DocHandle<unknown>;
}
).sub(...segments);
const cached = this.#subCache.get(backingSub.url);
if (cached) return cached as unknown as DocHandle<unknown>;
const presentedSubUrl = restampUrl(this.#originalUrl, backingSub.url);
const cached = this.#subCache.get(presentedSubUrl);
if (cached) return cached.wrapped as unknown as DocHandle<unknown>;
const wrapped = new OverlayHandle<unknown>({
presentedUrl: restampUrl(this.#originalUrl, backingSub.url),
presentedUrl: presentedSubUrl,
backing: backingSub,
});
this.#subCache.set(backingSub.url, wrapped);
this.#subCache.set(presentedSubUrl, { wrapped, segments });
return wrapped as unknown as DocHandle<unknown>;
}

Expand Down Expand Up @@ -173,18 +230,34 @@ export class OverlayHandle<T> {
return this.#self;
}

// Two deliberate departures from Node's EventEmitter: listener errors are
// logged rather than rethrown, so one failing consumer doesn't starve the
// rest; and listeners unsubscribed *during* the emit are skipped — a
// scopeReplaced consumer may tear down a later listener mid-emit (e.g.
// codemirror replacing its sync plugin), and invoking it anyway would
// re-apply state it already handed off.
emit(ev: string, ...args: unknown[]): boolean {
const set = this.#listeners.get(ev);
if (!set || set.size === 0) return false;
for (const fn of [...set]) fn(...args);
for (const fn of [...set]) {
if (!set.has(fn)) continue;
try {
fn(...args);
} catch (err) {
console.error(
`[patchwork-providers] "${ev}" listener on ${this.#originalUrl} threw:`,
err
);
}
}
return true;
}

// Lazily forward a backing event the first time someone subscribes to it,
// re-stamping `payload.handle = this wrapper` so consumers see the wrapper
// rather than the backing handle as the event source.
#forward(ev: string): void {
if (this.#forwarded.has(ev)) return;
if (this.#forwarders.has(ev)) return;
const forwarder: Listener = (payload: unknown) => {
if (
payload &&
Expand All @@ -196,7 +269,7 @@ export class OverlayHandle<T> {
this.emit(ev, payload);
}
};
this.#forwarded.set(ev, forwarder);
this.#forwarders.set(ev, forwarder);
(this.#handle as unknown as { on(ev: string, fn: Listener): void }).on(
ev,
forwarder
Expand All @@ -207,10 +280,10 @@ export class OverlayHandle<T> {
const backing = this.#handle as unknown as {
off(ev: string, fn: Listener): void;
};
for (const [ev, forwarder] of this.#forwarded) backing.off(ev, forwarder);
this.#forwarded.clear();
for (const [ev, forwarder] of this.#forwarders) backing.off(ev, forwarder);
this.#forwarders.clear();
this.#listeners.clear();
for (const sub of this.#subCache.values()) sub.dispose();
for (const entry of this.#subCache.values()) entry.wrapped.dispose();
this.#subCache.clear();
}
}
Expand Down
Loading
Loading