diff --git a/frontend/src/stimulus/mixins/use-services.spec.ts b/frontend/src/stimulus/mixins/use-services.spec.ts index 859da45172e..6bbdf45206f 100644 --- a/frontend/src/stimulus/mixins/use-services.spec.ts +++ b/frontend/src/stimulus/mixins/use-services.spec.ts @@ -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(); + stubPluginContext(() => context.promise); + + const { controller, element } = await mountController('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('use-services-test'); diff --git a/frontend/src/stimulus/mixins/use-services.ts b/frontend/src/stimulus/mixins/use-services.ts index 868df071fb9..3ca13418d29 100644 --- a/frontend/src/stimulus/mixins/use-services.ts +++ b/frontend/src/stimulus/mixins/use-services.ts @@ -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 = (map:(context:OpenProjectPluginContext) => T):Promise => { 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(); };