Skip to content

Commit 479b08d

Browse files
committed
[OP-19617] Seam the Angular-Turbo re-bootstrap
The wrapper destroys and re-bootstraps the whole Angular root on every Turbo navigation -- the most consequential frontend/src/turbo/ installer yet the only one left without coverage. Threads the same (target, signal) seam and injects the plugin-context lookup and bootstrap call so vitest can drive synthetic turbo:load events. The RxJS skip(1) pipeline and destroy-then-bootstrap contract are unchanged; callers stay on the defaults.
1 parent 20c9900 commit 479b08d

2 files changed

Lines changed: 175 additions & 6 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//-- copyright
2+
// OpenProject is an open source project management software.
3+
// Copyright (C) the OpenProject GmbH
4+
//
5+
// This program is free software; you can redistribute it and/or
6+
// modify it under the terms of the GNU General Public License version 3.
7+
//
8+
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
9+
// Copyright (C) 2006-2013 Jean-Philippe Lang
10+
// Copyright (C) 2010-2013 the ChiliProject Team
11+
//
12+
// This program is free software; you can redistribute it and/or
13+
// modify it under the terms of the GNU General Public License
14+
// as published by the Free Software Foundation; either version 2
15+
// of the License, or (at your option) any later version.
16+
//
17+
// This program is distributed in the hope that it will be useful,
18+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
// GNU General Public License for more details.
21+
//
22+
// You should have received a copy of the GNU General Public License
23+
// along with this program; if not, write to the Free Software
24+
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
25+
//
26+
// See COPYRIGHT and LICENSE files for more details.
27+
//++
28+
29+
import {
30+
afterEach, beforeEach, describe, expect, it, vi,
31+
} from 'vitest';
32+
import type { ApplicationRef, ComponentRef } from '@angular/core';
33+
import type { OpenProjectPluginContext } from 'core-app/features/plugins/plugin-context';
34+
import { addTurboAngularWrapper } from './turbo-angular-wrapper';
35+
36+
describe('addTurboAngularWrapper — Angular re-bootstrap on Turbo navigation', () => {
37+
let controller:AbortController;
38+
let target:EventTarget;
39+
40+
// The destroy-and-rebuild contract operates on the plugin context's appRef.
41+
// We drive it with fakes so the spec never touches `window.OpenProject` or a
42+
// real Angular application.
43+
function makeAppRef(components:ComponentRef<unknown>[]):ApplicationRef {
44+
return { components, detachView: vi.fn() } as unknown as ApplicationRef;
45+
}
46+
47+
// The handler awaits the plugin-context promise; flush the microtask queue so
48+
// assertions see the re-bootstrap that follows it.
49+
async function flush():Promise<void> {
50+
await Promise.resolve();
51+
await Promise.resolve();
52+
}
53+
54+
beforeEach(() => {
55+
controller = new AbortController();
56+
target = new EventTarget();
57+
});
58+
59+
afterEach(() => {
60+
controller.abort();
61+
});
62+
63+
it('re-bootstraps the Angular app on the second turbo:load', async () => {
64+
const appRef = makeAppRef([]);
65+
const bootstrap = vi.fn();
66+
const getPluginContext = () => Promise.resolve({ appRef } as OpenProjectPluginContext);
67+
68+
addTurboAngularWrapper({
69+
target, signal: controller.signal, getPluginContext, bootstrap,
70+
});
71+
72+
target.dispatchEvent(new Event('turbo:load')); // initial load — already bootstrapped
73+
await flush();
74+
target.dispatchEvent(new Event('turbo:load')); // navigation — must re-bootstrap
75+
await flush();
76+
77+
expect(bootstrap).toHaveBeenCalledTimes(1);
78+
expect(bootstrap).toHaveBeenCalledWith(appRef);
79+
});
80+
81+
it('tears down every existing root component before re-bootstrapping', async () => {
82+
// Hold the mocks as locals so the assertions never reference an unbound
83+
// method off the fake (eslint @typescript-eslint/unbound-method).
84+
const firstDestroy = vi.fn();
85+
const secondDestroy = vi.fn();
86+
const first = { hostView: {}, destroy: firstDestroy } as unknown as ComponentRef<unknown>;
87+
const second = { hostView: {}, destroy: secondDestroy } as unknown as ComponentRef<unknown>;
88+
const detachView = vi.fn();
89+
const appRef = { components: [first, second], detachView } as unknown as ApplicationRef;
90+
const bootstrap = vi.fn();
91+
const getPluginContext = () => Promise.resolve({ appRef } as OpenProjectPluginContext);
92+
93+
addTurboAngularWrapper({
94+
target, signal: controller.signal, getPluginContext, bootstrap,
95+
});
96+
97+
target.dispatchEvent(new Event('turbo:load')); // initial load — skipped
98+
await flush();
99+
target.dispatchEvent(new Event('turbo:load')); // navigation
100+
await flush();
101+
102+
expect(detachView).toHaveBeenCalledWith(first.hostView);
103+
expect(detachView).toHaveBeenCalledWith(second.hostView);
104+
expect(firstDestroy).toHaveBeenCalledOnce();
105+
expect(secondDestroy).toHaveBeenCalledOnce();
106+
// Teardown must complete before the fresh bootstrap.
107+
expect(firstDestroy).toHaveBeenCalledBefore(bootstrap);
108+
expect(secondDestroy).toHaveBeenCalledBefore(bootstrap);
109+
});
110+
111+
it('does not re-bootstrap on the initial turbo:load', async () => {
112+
const appRef = makeAppRef([]);
113+
const bootstrap = vi.fn();
114+
const getPluginContext = vi.fn(() => Promise.resolve({ appRef } as OpenProjectPluginContext));
115+
116+
addTurboAngularWrapper({
117+
target, signal: controller.signal, getPluginContext, bootstrap,
118+
});
119+
120+
target.dispatchEvent(new Event('turbo:load')); // initial load only
121+
await flush();
122+
123+
expect(getPluginContext).not.toHaveBeenCalled();
124+
expect(bootstrap).not.toHaveBeenCalled();
125+
});
126+
127+
it('stops re-bootstrapping once the signal aborts', async () => {
128+
const appRef = makeAppRef([]);
129+
const bootstrap = vi.fn();
130+
const getPluginContext = () => Promise.resolve({ appRef } as OpenProjectPluginContext);
131+
132+
addTurboAngularWrapper({
133+
target, signal: controller.signal, getPluginContext, bootstrap,
134+
});
135+
136+
controller.abort();
137+
138+
target.dispatchEvent(new Event('turbo:load'));
139+
await flush();
140+
target.dispatchEvent(new Event('turbo:load'));
141+
await flush();
142+
143+
expect(bootstrap).not.toHaveBeenCalled();
144+
});
145+
});
Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
11
import { skip } from 'rxjs/operators';
22
import { fromEvent } from 'rxjs';
3+
import type { ApplicationRef } from '@angular/core';
34
import { runBootstrap } from 'core-app/app.module';
45
import { OpenProjectPluginContext } from 'core-app/features/plugins/plugin-context';
56

6-
export function addTurboAngularWrapper() {
7+
export interface AngularTurboBridgeOptions {
8+
// The event source the bridge listens on. Defaults to the global `document`;
9+
// a spec can pass its own `EventTarget` to drive synthetic `turbo:load`
10+
// events in isolation.
11+
target?:EventTarget;
12+
// Aborts the `turbo:load` subscription so a spec (or future caller) can tear
13+
// the bridge down between cases.
14+
signal?:AbortSignal;
15+
// Resolves the Angular plugin context carrying the `appRef`. Defaults to the
16+
// real `window.OpenProject` lookup; injected so specs need no global.
17+
getPluginContext?:() => Promise<OpenProjectPluginContext>;
18+
// Re-bootstraps the root application onto a fresh `appBaseSelector`. Defaults
19+
// to `app.module`'s `runBootstrap`.
20+
bootstrap?:(appRef:ApplicationRef) => void;
21+
}
22+
23+
export function addTurboAngularWrapper(options:AngularTurboBridgeOptions = {}) {
24+
const {
25+
target = document,
26+
signal,
27+
getPluginContext = () => window.OpenProject.getPluginContext(),
28+
bootstrap = runBootstrap,
29+
} = options;
30+
731
// When turbo:load fires, the angular application needs to be rebootstrapped.
832
// However, we don't want this to happen on the initial page load
9-
fromEvent(document, 'turbo:load')
33+
const subscription = fromEvent(target, 'turbo:load')
1034
.pipe(
1135
skip(1), // Skip the first turbo:load event
1236
)
1337
.subscribe(() => {
14-
void window
15-
.OpenProject
16-
.getPluginContext()
38+
void getPluginContext()
1739
.then((pluginContext:OpenProjectPluginContext) => {
1840
const appRef = pluginContext.appRef;
1941

@@ -25,7 +47,9 @@ export function addTurboAngularWrapper() {
2547
});
2648

2749
// Run bootstrap again to initialize the new application
28-
runBootstrap(appRef);
50+
bootstrap(appRef);
2951
});
3052
});
53+
54+
signal?.addEventListener('abort', () => subscription.unsubscribe(), { once: true });
3155
}

0 commit comments

Comments
 (0)