Skip to content

Commit a21c032

Browse files
committed
fix(middleware): load template variables on deep-linked sub-path navigation
Template data was loaded only when the first request matched the exact main page URL. Opening a deep-linked sub-path (e.g. an SPA route) left template variables unloaded, breaking page injection. Load template data also on the first HTML-document navigation under the app base (detected via Sec-Fetch-Dest / Accept), so asset and XHR requests are unaffected; the load is cached and stays a no-op afterwards. The base is passed as a dedicated appBase option used only for navigation scoping, leaving the auth-failure redirect URL unchanged to avoid a redirect loop on v7. Add integration tests for sub-path, asset, and out-of-base requests.
1 parent d73700f commit a21c032

3 files changed

Lines changed: 166 additions & 4 deletions

File tree

src/lib/load-pp-data.middleware.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,44 @@ function setCachedResponse(key: string, data: any): void {
4242
// Constants
4343
const DEFAULT_REDIRECT_URL = '/home?proxyRedirect=';
4444

45+
/**
46+
* Whether the request is a top-level HTML document navigation (as opposed to an
47+
* asset/XHR/HMR request). Used to detect deep-linked page loads so template data
48+
* can be loaded even when the first request is not the main page URL.
49+
*/
50+
function isHtmlDocumentRequest(req: IncomingMessage): boolean {
51+
const dest = req.headers['sec-fetch-dest'];
52+
53+
if (typeof dest === 'string') {
54+
return dest === 'document';
55+
}
56+
57+
// Fallback for clients that don't send Sec-Fetch-* headers.
58+
const accept = req.headers['accept'];
59+
60+
return typeof accept === 'string' && accept.includes('text/html');
61+
}
62+
63+
/**
64+
* Whether the request path is served by this dev app (under its base path).
65+
* An empty/`/` base means the app is served from the root.
66+
*/
67+
function isUnderBase(requestPath: string, base?: string): boolean {
68+
if (!base || base === '/') {
69+
return true;
70+
}
71+
72+
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
73+
74+
return requestPath === normalizedBase || requestPath === normalizedBase.slice(0, -1) || requestPath.startsWith(normalizedBase);
75+
}
76+
4577
export function initLoadPPData(
4678
applyUrlRegExp: RegExp,
4779
mi: MiAPI,
48-
opts: PPDevConfig & { base?: string },
80+
opts: PPDevConfig & { base?: string; appBase?: string },
4981
): NextHandleFunction {
50-
const { templateLess = false, miHudLess = false, appId, base, v7Features } = opts;
82+
const { templateLess = false, miHudLess = false, appId, base, appBase, v7Features } = opts;
5183

5284
const logger = createLogger();
5385

@@ -65,7 +97,19 @@ export function initLoadPPData(
6597
return async (req: IncomingMessage, res: ServerResponse, next: NextFunction) => {
6698
try {
6799
const isNeedTemplateLoad = !(templateLess && miHudLess);
68-
const isApplyRequest = applyUrlRegExp.test(cutUrlParams(req.url ?? ''));
100+
const requestPath = cutUrlParams(req.url ?? '');
101+
const isApplyRequest = applyUrlRegExp.test(requestPath);
102+
103+
// A deep-linked navigation into the app (e.g. an SPA sub-route) that is not the
104+
// exact main page URL. Without loading here, template variables would never be
105+
// fetched when the very first request is such a sub-path. Excludes `/home`,
106+
// which is handled by the auth/redirect block above. The load itself is cached,
107+
// so this stays a no-op after the first navigation.
108+
const isAppNavigation =
109+
!isApplyRequest &&
110+
!requestPath.startsWith('/home') &&
111+
isUnderBase(requestPath, appBase) &&
112+
isHtmlDocumentRequest(req);
69113

70114
// 1. If !isAuthenticated && !isRedirected and url started with /home - try to handle load page or template
71115
if (
@@ -121,7 +165,7 @@ export function initLoadPPData(
121165
}
122166

123167
// Default case - continue with normal flow
124-
if (!isApplyRequest) {
168+
if (!isApplyRequest && !isAppNavigation) {
125169
return next();
126170
}
127171

src/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin {
606606
Object.assign({}, options, {
607607
appId: normalizedAppId,
608608
portalPageId: normalizedAppId,
609+
appBase: base,
609610
}),
610611
),
611612
);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { createServer, type Server } from 'http';
3+
import { initLoadPPData, clearAPICache } from '../../../src/lib/load-pp-data.middleware.js';
4+
import type { MiAPI } from '../../../src/lib/pp.middleware.js';
5+
6+
function createTestServer(middleware: any): Promise<{ server: Server; port: number }> {
7+
return new Promise((resolve) => {
8+
const server = createServer((req, res) => {
9+
const next = () => {
10+
res.statusCode = 200;
11+
res.end('OK');
12+
};
13+
middleware(req, res, next);
14+
});
15+
16+
server.listen(0, () => {
17+
const address = server.address();
18+
const port = typeof address === 'object' ? (address?.port ?? 0) : 0;
19+
20+
resolve({ server, port });
21+
});
22+
});
23+
}
24+
25+
function makeRequest(port: number, path: string, headers: Record<string, string> = {}): Promise<{ status: number }> {
26+
return new Promise((resolve) => {
27+
const http = require('http');
28+
const req = http.request({ hostname: 'localhost', port, path, method: 'GET', headers }, (res: any) => {
29+
res.on('data', () => {});
30+
res.on('end', () => resolve({ status: res.statusCode }));
31+
});
32+
req.end();
33+
});
34+
}
35+
36+
const DOCUMENT_HEADERS = { accept: 'text/html', 'sec-fetch-dest': 'document' };
37+
const SCRIPT_HEADERS = { accept: '*/*', 'sec-fetch-dest': 'script' };
38+
39+
/**
40+
* Regression tests for the bug where template variables were not loaded when the
41+
* first dev-server request was a deep-linked sub-path instead of the main page URL.
42+
*/
43+
describe('initLoadPPData — load on deep-linked sub-path navigation', () => {
44+
const APP_BASE = '/pl/foo/';
45+
const isIndexRegExp = new RegExp(`^((${APP_BASE})|/)$`);
46+
47+
let mi: MiAPI;
48+
let getPageVariables: ReturnType<typeof vi.fn>;
49+
let getPageTemplate: ReturnType<typeof vi.fn>;
50+
51+
function buildMiddleware() {
52+
return initLoadPPData(isIndexRegExp, mi, {
53+
appId: 123,
54+
templateLess: false,
55+
miHudLess: false,
56+
v7Features: true,
57+
appBase: APP_BASE,
58+
} as any);
59+
}
60+
61+
beforeEach(() => {
62+
clearAPICache();
63+
64+
getPageVariables = vi.fn().mockResolvedValue([]);
65+
getPageTemplate = vi.fn().mockResolvedValue('<html></html>');
66+
67+
mi = { getPageVariables, getPageTemplate, getPageInfo: vi.fn().mockResolvedValue({}) } as unknown as MiAPI;
68+
});
69+
70+
it('loads template variables for an HTML-document sub-path (the bug)', async () => {
71+
const { server, port } = await createTestServer(buildMiddleware());
72+
73+
try {
74+
await makeRequest(port, '/pl/foo/dashboard/widget', DOCUMENT_HEADERS);
75+
76+
expect(getPageVariables).toHaveBeenCalledTimes(1);
77+
} finally {
78+
server.close();
79+
}
80+
});
81+
82+
it('loads template variables for the exact main page URL', async () => {
83+
const { server, port } = await createTestServer(buildMiddleware());
84+
85+
try {
86+
await makeRequest(port, '/pl/foo/', DOCUMENT_HEADERS);
87+
88+
expect(getPageVariables).toHaveBeenCalledTimes(1);
89+
} finally {
90+
server.close();
91+
}
92+
});
93+
94+
it('does NOT load for asset (non-document) requests under base', async () => {
95+
const { server, port } = await createTestServer(buildMiddleware());
96+
97+
try {
98+
await makeRequest(port, '/pl/foo/assets/app.js', SCRIPT_HEADERS);
99+
100+
expect(getPageVariables).not.toHaveBeenCalled();
101+
} finally {
102+
server.close();
103+
}
104+
});
105+
106+
it('does NOT load for sub-paths outside the app base', async () => {
107+
const { server, port } = await createTestServer(buildMiddleware());
108+
109+
try {
110+
await makeRequest(port, '/pl/other-app/dashboard', DOCUMENT_HEADERS);
111+
112+
expect(getPageVariables).not.toHaveBeenCalled();
113+
} finally {
114+
server.close();
115+
}
116+
});
117+
});

0 commit comments

Comments
 (0)