feat(client): support settings-default entry mode (#10267)

This commit is contained in:
Katherine
2026-08-03 21:04:49 +08:00
committed by GitHub
parent cdabf5b9bc
commit f2fdb1d383
9 changed files with 95 additions and 10 deletions
+6 -2
View File
@@ -48,6 +48,10 @@ _Avoid_: hybrid modern-only, redirect-all
An **App client entry mode** where legacy client document entries hand off to the **Modern client public path**; in production this is mainly a browser-side handoff, while dev additionally avoids running the legacy client dev server and redirects non-`/v/` entries into `/v/`.
_Avoid_: compatible modern-only, route-mapped modern-only
**Settings-default**:
An **App client entry mode** where the app root hands off to the standalone Settings application at `${APP_PUBLIC_PATH}settings/`, while the **Modern client** and legacy deep links remain available at their own paths.
_Avoid_: settings-only, no-portal mode
**Client document entry request**:
An HTTP request whose job is to load a client HTML entry, not an API, websocket, upload, dist, or plugin-static resource.
_Avoid_: all frontend request, browser request
@@ -58,9 +62,9 @@ _Avoid_: all frontend request, browser request
- The **Legacy client** is served at the **App public path**; the **Modern client** is served at the **Modern client public path** nested inside it
- **App public path** and **Modern client prefix** vary independently; both default such that the modern client lands at `/v/`
- The **Site root** is not always the **App public path**
- The **App client entry mode** chooses the default entry behavior independently from the concrete route trees owned by the **Legacy client** and the **Modern client**
- The **App client entry mode** chooses the default entry behavior independently from the concrete route trees owned by the **Legacy client**, the **Modern client**, and the standalone Settings application
- A **Legacy client** deep link is not assumed to have a one-to-one **Modern client** deep link
- In production, the **Legacy client** shell is the main place where **App client entry mode** hands off document entries to the **Modern client**
- In production, the **Legacy client** shell is the main place where **App client entry mode** hands off document entries to the configured default client
- In dev, only **Modern-only** adds extra runtime handling so the legacy dev server is not started and non-`/v/` entries are redirected into the modern dev entry
## Example dialogue
@@ -75,7 +75,7 @@ function runBrowserChecker(options: RunBrowserCheckerOptions) {
}
describe('v2 browser checker', () => {
it.each([undefined, 'modern-default', 'modern-only'] as const)(
it.each([undefined, 'modern-default', 'modern-only', 'settings-default'] as const)(
'lets the modern client handle its root for entry mode %s',
(appClientEntryMode) => {
expect(
@@ -87,7 +87,7 @@ describe('v2 browser checker', () => {
},
);
it.each([undefined, 'modern-default', 'modern-only'] as const)(
it.each([undefined, 'modern-default', 'modern-only', 'settings-default'] as const)(
'lets scoped modern clients handle their roots for entry mode %s',
(appClientEntryMode) => {
expect(
@@ -54,7 +54,7 @@ if (basename !== '/' && currentPath === basenameWithoutTrailingSlash) {
// This client-side redirect is still needed because legacy `index.html` is
// not always served through the node gateway. In nginx/static delivery paths
// the browser may already be running the legacy shell by the time entry-mode
// logic is evaluated, so the last hop into the modern entry has to be
// logic is evaluated, so the last hop into the configured default entry has to be
// recoverable in the browser as well.
const normalizedPath = normalizePathname(currentPath);
const relativePath =
@@ -66,15 +66,22 @@ if (basename !== '/' && currentPath === basenameWithoutTrailingSlash) {
? normalizePathname(normalizedPath.slice(basename.length - 1))
: null;
const modernBase = `${trimTrailingSlash(basename)}/${modernClientPrefix}/`.replace(/\/{2,}/g, '/');
const settingsBase = `${basename}settings/`;
const isModernDefault = appClientEntryMode === 'modern-default';
const isModernOnly = appClientEntryMode === 'modern-only';
const isSettingsDefault = appClientEntryMode === 'settings-default';
const targetBase = isSettingsDefault ? settingsBase : modernBase;
if (
relativePath &&
isClientDocumentEntryPath(relativePath) &&
(isModernDefault || isModernOnly) &&
!normalizedPath.startsWith(modernBase)
(isModernDefault || isModernOnly || isSettingsDefault) &&
!normalizedPath.startsWith(targetBase)
) {
const targetPath = isModernDefault
const targetPath = isSettingsDefault
? relativePath === '/' || relativePath === '/index.html'
? settingsBase
: null
: isModernDefault
? relativePath === '/' || relativePath === '/index.html'
? relativePath === '/index.html'
? `${modernBase}index.html`
@@ -87,6 +87,20 @@ describe('cli-v1 buildIndexHtml', () => {
fs.removeSync(appRoot);
});
test('injects settings-default as a valid client entry mode', () => {
const appRoot = createAppPackageRoot();
process.argv = ['node', 'nocobase-v1', 'start'];
process.env.APP_PACKAGE_ROOT = appRoot;
process.env.APP_PUBLIC_PATH = '/';
process.env.APP_CLIENT_ENTRY_MODE = 'settings-default';
buildIndexHtml();
const html = fs.readFileSync(path.join(appRoot, 'dist/client/index.html'), 'utf-8');
expect(html).toContain("window['__nocobase_app_client_entry_mode__'] = 'settings-default';");
fs.removeSync(appRoot);
});
test('refreshes cached tpl when new runtime placeholders are missing', () => {
const appRoot = createAppPackageRoot();
const tplPath = path.join(appRoot, 'dist/client/index.html.tpl');
@@ -67,6 +67,16 @@ describe('cli-v1 dev command', () => {
});
});
test('resolveDevRuntimeMode keeps all clients in settings-default', () => {
expect(resolveDevRuntimeMode({ appClientEntryMode: 'settings-default' })).toMatchObject({
useModernOnlyEntryMode: false,
shouldRunClient: true,
shouldRunClientV2: true,
shouldRunSettings: true,
shouldRunServer: true,
});
});
test('resolveDevRuntimeMode preserves explicit client-v2-only flag behavior', () => {
expect(resolveDevRuntimeMode({ clientV2Only: true, appClientEntryMode: 'legacy-default' })).toMatchObject({
useModernOnlyEntryMode: false,
+1 -1
View File
@@ -433,7 +433,7 @@ const DEFAULT_MODERN_CLIENT_PREFIX = 'v';
exports.DEFAULT_MODERN_CLIENT_PREFIX = DEFAULT_MODERN_CLIENT_PREFIX;
const DEFAULT_APP_CLIENT_ENTRY_MODE = 'legacy-default';
const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only']);
const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only', 'settings-default']);
exports.DEFAULT_APP_CLIENT_ENTRY_MODE = DEFAULT_APP_CLIENT_ENTRY_MODE;
@@ -279,6 +279,19 @@ test('buildEnvProxyNginxBundle renders app.conf and index HTML with CDN-prefixed
expect(bundle.indexSettingsContent).toContain('src="/console/dist/2.1.0-beta.44/settings/assets/runtime.js"');
});
test('buildEnvProxyNginxBundle preserves settings-default in each client entry', async () => {
const root = await createTempRoot('nocobase-cli-env-proxy-settings-default-');
const runtime = await createLocalRuntime(root, {
appClientEntryMode: 'settings-default',
});
const bundle = await buildEnvProxyNginxBundle(runtime);
expect(bundle.indexV1Content).toContain(`window['__nocobase_app_client_entry_mode__'] = "settings-default";`);
expect(bundle.indexV2Content).toContain(`window['__nocobase_app_client_entry_mode__'] = "settings-default";`);
expect(bundle.indexSettingsContent).toContain(`window['__nocobase_app_client_entry_mode__'] = "settings-default";`);
});
test('buildEnvProxyNginxBundle omits the root redirect block for root-mounted apps', async () => {
const root = await createTempRoot('nocobase-cli-env-proxy-nginx-root-');
process.env.NB_CLI_ROOT = root;
+1 -1
View File
@@ -35,7 +35,7 @@ const DEFAULT_PLUGIN_STATICS_PATH = '/static/plugins/';
const DEFAULT_MODERN_CLIENT_PREFIX = 'v';
const SETTINGS_CLIENT_PREFIX = 'settings';
const DEFAULT_APP_CLIENT_ENTRY_MODE = 'legacy-default';
const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only']);
const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only', 'settings-default']);
const DEFAULT_API_CLIENT_STORAGE_PREFIX = 'NOCOBASE_';
const DEFAULT_API_CLIENT_STORAGE_TYPE = 'localStorage';
const DEFAULT_ESM_CDN_BASE_URL = 'https://esm.sh';
@@ -147,6 +147,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
});
it.each(['/', '/index.html'])('redirects app root entry %s to Settings for settings-default', (pathname) => {
const replace = executeBrowserChecker(scriptPath, {
pathname,
publicPath: '/',
modernClientPrefix: 'v',
appClientEntryMode: 'settings-default',
search: '?from=entry',
hash: '#panel',
});
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings/?from=entry#panel');
});
it('does not redirect legacy deep links for modern-default', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/admin',
@@ -158,6 +171,17 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
expect(replace).not.toHaveBeenCalled();
});
it('does not redirect legacy deep links for settings-default', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/admin',
publicPath: '/',
modernClientPrefix: 'v',
appClientEntryMode: 'settings-default',
});
expect(replace).not.toHaveBeenCalled();
});
it('rewrites legacy document paths for modern-only', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/admin/settings/workflow',
@@ -180,6 +204,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
});
it('redirects a sub-path app root directly to Settings for settings-default', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/nocobase/',
publicPath: '/nocobase/',
modernClientPrefix: 'v',
appClientEntryMode: 'settings-default',
search: '?from=entry',
hash: '#panel',
});
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/settings/?from=entry#panel');
});
it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/nocobase/apps/a_31itq60q4kg/admin/',