fix(calendar): show agenda-view plugin events without re-navigation (#8811)

Plugin issue-provider calendars (e.g. the Google Calendar plugin) register
asynchronously after the issue-provider store is hydrated. calendarEvents$
read getUseAgendaView() imperatively inside a store-stream map, so on first
load the filter ran before the plugin registered (returning false) and the
plugin's events stayed absent until a re-subscription (navigating away and
back) re-projected the stream.

Expose registration changes from the registry as a BehaviorSubject-backed
registrationChanges$ and fold it into the plugin-provider filter so it re-runs
when a plugin (un)registers. A BehaviorSubject (not toObservable(signal)) keeps
calendarEvents$ emitting synchronously on subscribe.
This commit is contained in:
Johannes Millan 2026-07-07 13:43:25 +02:00 committed by GitHub
parent 9ae49a9e82
commit b30c861b96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 139 additions and 7 deletions

View file

@ -29,6 +29,7 @@ import { Subscription } from 'rxjs';
import { getDbDateStr } from '../../util/get-db-date-str';
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
import { PluginHttpService } from '../../plugins/issue-provider/plugin-http.service';
import { IssueProviderPluginDefinition } from '../../plugins/issue-provider/plugin-issue-provider.model';
import { IssueProviderPluginType } from '../issue/issue.model';
import { NotIcalResponseError } from '../schedule/ical/is-likely-ical';
// Static import forces ical.js into the main test bundle so the dynamic
@ -2284,4 +2285,104 @@ END:VCALENDAR`;
expect(allDay.dueWithTime).toBeUndefined();
});
});
// Regression: plugin issue-provider calendars (e.g. the Google Calendar plugin) register
// asynchronously AFTER the issue-provider store is hydrated. calendarEvents$ must react to
// that registration (via PluginIssueProviderRegistryService.registrationChanges$) and surface
// the plugin's events WITHOUT needing a re-subscription — otherwise agenda events only appear
// after navigating away and back to the Today view.
describe('plugin registering after subscription', () => {
it('surfaces plugin calendar events once the plugin registers (no re-subscribe)', fakeAsync(() => {
const PLUGIN_KEY = 'plugin:gcal';
const pluginProvider = {
id: 'gcal-provider-id',
issueProviderKey: PLUGIN_KEY,
isEnabled: true,
pluginConfig: {},
} as unknown as IssueProviderPluginType;
const twoHoursMs = 2 * 60 * 60 * 1000;
const futureStart = Date.now() + twoHoursMs;
const getNewIssuesForBacklog = jasmine
.createSpy('getNewIssuesForBacklog')
.and.returnValue(
Promise.resolve([
{
id: 'gcal-evt-1',
title: 'Standup',
start: futureStart,
dueWithTime: futureStart,
duration: 30 * 60 * 1000,
},
]),
);
const mockPluginHttp = {
createHttpHelper: jasmine.createSpy('createHttpHelper').and.returnValue({}),
};
TestBed.resetTestingModule();
localStorage.clear();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
CalendarIntegrationService,
provideMockStore({
selectors: [
{ selector: selectCalendarProviders, value: [] },
// The provider config is already hydrated in the store before the plugin
// (which supplies `useAgendaView`) has registered.
{ selector: selectEnabledIssueProviders, value: [pluginProvider] },
{ selector: selectAllCalendarTaskEventIds, value: [] },
],
}),
{ provide: SnackService, useValue: mockSnackService },
{ provide: PluginHttpService, useValue: mockPluginHttp },
{ provide: TaskArchiveService, useValue: mockTaskArchiveService },
],
});
const freshService = TestBed.inject(CalendarIntegrationService);
const registry = TestBed.inject(PluginIssueProviderRegistryService);
const emissions: string[][] = [];
const sub = freshService.calendarEvents$.subscribe((entries) =>
emissions.push(entries.flatMap((e) => e.items.map((i) => i.id))),
);
// Before registration `getUseAgendaView` is false → the provider is not treated as a
// calendar source and nothing is fetched.
tick(0);
flushMicrotasks();
expect(emissions.flat()).not.toContain('gcal-evt-1');
expect(getNewIssuesForBacklog).not.toHaveBeenCalled();
// Plugin finishes loading and registers as an agenda-view calendar provider.
registry.register({
pluginId: 'gcal',
issueProviderKey: PLUGIN_KEY,
definition: {
getHeaders: () => ({}),
getNewIssuesForBacklog,
} as unknown as IssueProviderPluginDefinition,
name: 'Google Calendar',
humanReadableName: 'Google Calendar',
icon: 'calendar',
pollIntervalMs: 60000,
issueStrings: { singular: 'Event', plural: 'Events' },
useAgendaView: true,
});
// Without any re-subscription, the plugin's event must now appear.
tick(0);
flushMicrotasks();
tick(100);
flushMicrotasks();
expect(getNewIssuesForBacklog).toHaveBeenCalled();
expect(emissions[emissions.length - 1]).toContain('gcal-evt-1');
sub.unsubscribe();
discardPeriodicTasks();
}));
});
});

View file

@ -120,8 +120,18 @@ export class CalendarIntegrationService {
this._store
.select(selectCalendarProviders)
.pipe(distinctUntilChanged(fastArrayCompare)),
this._store.select(selectEnabledIssueProviders).pipe(
map((providers) =>
// `registrationChanges$` emits when a plugin (un)registers. Plugins load
// asynchronously after bootstrap, while the issue-provider store is hydrated
// early — so without this trigger `getUseAgendaView` is read once (before the
// plugin registers, returning false), the store never re-emits, and the
// plugin's calendar events stay absent until a re-subscription forces a
// re-projection (e.g. navigating away and back). Re-run the filter on
// registration so agenda-view plugin events surface without navigation.
combineLatest([
this._store.select(selectEnabledIssueProviders),
this._pluginRegistry.registrationChanges$,
]).pipe(
map(([providers]) =>
providers.filter(
(p): p is IssueProviderPluginType =>
isPluginIssueProvider(p.issueProviderKey) &&

View file

@ -1,4 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { BehaviorSubject, Observable } from 'rxjs';
import {
RegisteredPluginIssueProvider,
IssueProviderPluginDefinition,
@ -16,8 +18,21 @@ export class PluginIssueProviderRegistryService {
/** Maps pluginId → registeredKey for cleanup */
private _pluginIdToKey = new Map<string, string>();
/** Signal that increments on each registration/unregistration, so computed signals can react */
readonly registrationVersion = signal(0);
/**
* Single source of truth for "a plugin (un)registered", exposed two ways so signal-
* and stream-based consumers react to the same event without drifting:
* - `registrationChanges$` (RxJS) for observable pipelines that must re-run without an
* effect/CD flush AND emit synchronously on subscribe e.g.
* `CalendarIntegrationService.calendarEvents$`. (A `toObservable(signal)` only emits
* once its effect runs, delaying even the initial value; a `BehaviorSubject` does not.)
* - `registrationVersion` (signal) for `computed()` consumers, e.g. `tag-list`.
*/
private readonly _registrationVersion$ = new BehaviorSubject(0);
readonly registrationChanges$: Observable<number> =
this._registrationVersion$.asObservable();
readonly registrationVersion = toSignal(this._registrationVersion$, {
requireSync: true,
});
register(opts: {
pluginId: string;
@ -53,7 +68,7 @@ export class PluginIssueProviderRegistryService {
allowPrivateNetwork: opts.allowPrivateNetwork,
});
this._pluginIdToKey.set(opts.pluginId, key);
this.registrationVersion.update((v) => v + 1);
this._bumpRegistrationVersion();
}
unregister(pluginId: string): void {
@ -61,10 +76,16 @@ export class PluginIssueProviderRegistryService {
if (key) {
this._providers.delete(key);
this._pluginIdToKey.delete(pluginId);
this.registrationVersion.update((v) => v + 1);
this._bumpRegistrationVersion();
}
}
/** Advance the single registration counter; both `registrationChanges$` and the
* derived `registrationVersion` signal update from it. */
private _bumpRegistrationVersion(): void {
this._registrationVersion$.next(this._registrationVersion$.value + 1);
}
/** Get the registered key for a pluginId (e.g. 'GITHUB' or 'plugin:my-plugin') */
getRegisteredKey(pluginId: string): string | undefined {
return this._pluginIdToKey.get(pluginId);