Invalidate promises requested while disconnected

Any `services` or `pluginContext` promise requested between a disconnect
and a reconnect of the same controller instance still resolved after the
context arrived, because the epoch only ever advanced on disconnect. The
epoch now also advances on reconnect.
This commit is contained in:
Alexander Brandon Coles
2026-06-11 12:56:50 +01:00
parent 3203b63b44
commit 275eda0c96
2 changed files with 34 additions and 4 deletions
@@ -226,6 +226,27 @@ describe('useServices', () => {
expect(resolved).not.toHaveBeenCalled();
});
it('never resolves a services promise requested while disconnected once reconnected', async () => {
const context = deferred<unknown>();
stubPluginContext(() => context.promise);
const { controller, element } = await mountController<TestController>('use-services-test');
element.remove();
await ctx.nextFrame();
const resolved = vi.fn();
void controller.services.then(resolved);
ctx.container.append(element);
await ctx.nextFrame();
context.resolve(pluginContext);
await ctx.nextFrame();
expect(resolved).not.toHaveBeenCalled();
});
it('exposes a pluginContext promise resolving to the full context', async () => {
const { controller } = await mountController<TestController>('use-services-test');
+13 -4
View File
@@ -67,16 +67,20 @@ interface ServiceConsumer {
* - `this.pluginContext` — resolves to the full `OpenProjectPluginContext`,
* the escape hatch for `classes`, `helpers` and `injector`
*
* Both promises never settle while the controller is disconnected at context
* resolution time — whether obtained before or after the disconnect — so code
* after an `await` cannot act on a dead element.
* Both promises never settle once the controller disconnects — whether they
* were obtained before the disconnect or while disconnected — so code after
* an `await` cannot act on a dead element. Only promises obtained from the
* current connection resolve.
*/
export function useServices(controller:Controller):void {
const declaredServices = (controller.constructor as unknown as { services?:ServiceKey[] }).services ?? [];
// Each disconnect invalidates anything still pending from the previous
// connection — replaces the per-controller Symbol-token pattern.
// connection, and a reconnect invalidates anything requested while
// disconnected — replaces the per-controller Symbol-token pattern. The
// first connect must not bump: initialize() may already hold promises.
let epoch = 0;
let disconnected = false;
const guarded = <T>(map:(context:OpenProjectPluginContext) => T):Promise<T> => {
const token = epoch;
@@ -123,12 +127,17 @@ export function useServices(controller:Controller):void {
const originalDisconnect = controller.disconnect.bind(controller);
controller.connect = () => {
if (disconnected) {
epoch += 1;
disconnected = false;
}
originalConnect();
void connectServices();
};
controller.disconnect = () => {
epoch += 1;
disconnected = true;
originalDisconnect();
};