Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-05-27 08:29:14 +00:00
4 changed files with 374 additions and 25 deletions
@@ -16,53 +16,127 @@ export interface ExtendCollectionsProviderProps {
dataSource?: string;
/** Collections to surface for the lifetime of this provider's subtree. */
collections: CollectionOptions[];
/**
* When `true`, re-sync the data source whenever the `collections` prop
* reference changes after mount: add entries newly present in the prop and
* remove entries no longer present (only those this provider registered).
* The diff runs in the same render as the prop change so children see the
* new state on their first render — at the cost of one observable mutation
* per change.
*
* Defaults to `false`. Most pages pass a stable (often module-level)
* `collections` list and don't need this; leaving it off avoids accidental
* re-registration when callers forget to memoize. Enable only when your
* collection list legitimately varies during the provider's lifetime.
*/
syncOnChange?: boolean;
children?: ReactNode;
}
/**
* Mount-scoped collection injector. Adds the given `collections` to the target data source on mount and removes them on unmount. Survives mid-session reloads via `dataSource:loaded` events.
* Mount-scoped collection injector. Adds the given `collections` to the target
* data source on first render — synchronously, so children can read
* `getCollection(name)` on their own first render — and removes them on
* unmount. Survives mid-session data-source reloads via the
* `dataSource:loaded` event by re-registering only the names this provider
* owns.
*
* Use this for client-only collections — e.g. a `schema-only` server collection that isn't auto-published to the v2 data source, or a pure UI-side mirror — so downstream components (like `<CollectionFilter>`) can resolve the collection by name.
* Use this for client-only collections — e.g. a `schema-only` server
* collection that isn't auto-published to the v2 data source, or a pure
* UI-side mirror — so downstream components (like `<CollectionFilter>`) can
* resolve the collection by name.
*
* Default behavior is "static-at-mount": subsequent changes to the
* `collections` prop are ignored. Pass `syncOnChange` to opt into diffing on
* prop change.
*/
export const ExtendCollectionsProvider: FC<ExtendCollectionsProviderProps> = ({
dataSource = 'main',
collections,
syncOnChange = false,
children,
}) => {
const app = useApp();
const ownedRef = useRef<Set<string>>(new Set());
// Lazy-ref init guard. `ownedRef.current === null` only on the first render;
// once populated, StrictMode dev's second render and any subsequent
// re-render see a non-null ref and skip re-registering. React docs bless
// this idiom for "init exactly once on mount" — see "Avoiding recreating
// the ref contents".
const ownedRef = useRef<CollectionOptions[] | null>(null);
// Identity of the `collections` reference we last reacted to; gates the
// opt-in diff so StrictMode's double-render doesn't diff twice.
const lastCollectionsRef = useRef<CollectionOptions[] | null>(null);
const apply = () => {
if (ownedRef.current === null) {
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
if (!ds) return;
for (const collection of collections) {
if (ds.getCollection?.(collection.name)) continue;
ds.addCollection?.(collection);
ownedRef.current.add(collection.name);
const owned: CollectionOptions[] = [];
if (ds) {
for (const c of collections) {
if (ds.getCollection?.(c.name)) continue;
ds.addCollection?.(c);
owned.push(c);
}
}
};
apply();
ownedRef.current = owned;
lastCollectionsRef.current = collections;
} else if (syncOnChange && lastCollectionsRef.current !== collections) {
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
if (ds) {
const nextNames = new Set(collections.map((c) => c.name));
const prevOwned = new Map(ownedRef.current.map((c) => [c.name, c]));
for (const name of prevOwned.keys()) {
if (!nextNames.has(name)) ds.removeCollection?.(name);
}
const nextOwned: CollectionOptions[] = [];
for (const c of collections) {
const previous = prevOwned.get(c.name);
if (previous) {
// First-registered wins: keep the existing options object, mirroring
// the original behavior where re-adding a present name was a no-op.
// Callers who need to update a collection should remount the
// provider (e.g. `key={signature}`).
nextOwned.push(previous);
continue;
}
if (ds.getCollection?.(c.name)) continue;
ds.addCollection?.(c);
nextOwned.push(c);
}
ownedRef.current = nextOwned;
}
lastCollectionsRef.current = collections;
}
useEffect(() => {
const onLoaded = (event: Event) => {
const key = (event as CustomEvent<{ dataSourceKey: string }>).detail?.dataSourceKey;
if (key === dataSource || key === '*') apply();
if (key !== dataSource && key !== '*') return;
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
if (!ds || !ownedRef.current) return;
// dataSource was just reloaded from the server — our owned client-only
// entries got wiped. Re-add only the ones we own, using the snapshot in
// the ref so we don't accidentally seize names this provider never
// registered.
for (const c of ownedRef.current) {
if (ds.getCollection?.(c.name)) continue;
ds.addCollection?.(c);
}
};
app.eventBus?.addEventListener('dataSource:loaded', onLoaded);
return () => {
app.eventBus?.removeEventListener('dataSource:loaded', onLoaded);
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
const owned = ownedRef.current;
ownedRef.current = new Set();
const owned = ownedRef.current ?? [];
ownedRef.current = null;
lastCollectionsRef.current = null;
if (!ds) return;
for (const name of owned) {
ds.removeCollection?.(name);
}
for (const c of owned) ds.removeCollection?.(c.name);
};
// `collections` / `syncOnChange` intentionally excluded — they drive the
// render-phase init/diff above, not this effect. The listener reads
// `ownedRef` each time it fires (async, never during render).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app, dataSource, collections]);
}, [app, dataSource]);
return <>{children}</>;
};
@@ -0,0 +1,264 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { CollectionOptions } from '@nocobase/flow-engine';
import { FlowEngineProvider } from '@nocobase/flow-engine';
import { act, render } from '@testing-library/react';
import React, { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { createMockClient } from '../../MockApplication';
import { ExtendCollectionsProvider } from '../ExtendCollectionsProvider';
// `MockApplication` isn't exported as a named type, so infer from the factory.
type AppInstance = ReturnType<typeof createMockClient>;
function makeApp(): AppInstance {
const app = createMockClient();
// The mock client wires a lazy `appInfo` getter to `GET app:getInfo`; any
// proxy iteration of the FlowEngineContext can trip it. Pre-stub so a 404
// doesn't surface as an unhandled axios rejection during the test run.
app.apiMock.onGet('app:getInfo').reply(200, { data: { version: 'test' } });
return app;
}
const LOCKED: CollectionOptions = {
name: 'lockedUsers',
fields: [{ name: 'id', type: 'integer', interface: 'integer' }],
};
const USERS: CollectionOptions = {
name: 'users',
fields: [{ name: 'username', type: 'string', interface: 'input' }],
};
const POSTS: CollectionOptions = {
name: 'posts',
fields: [{ name: 'title', type: 'string', interface: 'input' }],
};
function mountWith(app: AppInstance, node: React.ReactNode) {
return render(<FlowEngineProvider engine={app.flowEngine}>{node}</FlowEngineProvider>);
}
function getMain(app: AppInstance) {
return app.dataSourceManager.getDataSource('main');
}
describe('ExtendCollectionsProvider', () => {
// The defining contract: a page-inner that reads `getCollection(name)` in its
// own render body (the LockedUsersPage pattern at LockedUsersPage.tsx:156-157)
// must see the registered collection on its FIRST render, with no extra tick.
it('lets children read the registered collection during their first render', () => {
const app = makeApp();
let firstRenderResult: { name?: string } | undefined;
const Child: React.FC = () => {
// Read synchronously during render — this is the contract that forces
// the provider to register collections in render-phase rather than in
// an effect.
const found = getMain(app)?.getCollection?.(LOCKED.name);
firstRenderResult = found ? { name: found.name } : undefined;
return <div>child</div>;
};
mountWith(
app,
<ExtendCollectionsProvider collections={[LOCKED]}>
<Child />
</ExtendCollectionsProvider>,
);
expect(firstRenderResult).toEqual({ name: LOCKED.name });
});
it('removes only the collections it added when unmounted', () => {
const app = makeApp();
// Pre-existing collection in the data source — provider must not touch it.
getMain(app).addCollection(USERS);
const { unmount } = mountWith(
app,
<ExtendCollectionsProvider collections={[LOCKED]}>
<span>inside</span>
</ExtendCollectionsProvider>,
);
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
unmount();
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
// The provider didn't add USERS, so it must leave it alone.
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
});
// Direct regression for the lazy-ref idempotency: even when the provider
// re-renders many times (which is what StrictMode dev's double-render and
// any caller-driven re-render look like to the provider), it must not
// re-register the same collection or accumulate duplicate ownership.
it('only calls addCollection once across many re-renders of the same mount', () => {
const app = makeApp();
let addCount = 0;
const origAdd = getMain(app).addCollection.bind(getMain(app));
getMain(app).addCollection = (c: CollectionOptions) => {
addCount += 1;
origAdd(c);
};
const Host: React.FC = () => {
const [, setTick] = useState(0);
// Force a render burst on mount — covers StrictMode dev's second render
// and any other parent-driven re-renders. The lazy-ref guard in the
// provider must hold across all of them.
React.useEffect(() => {
setTick((n) => n + 1);
setTick((n) => n + 1);
}, []);
return (
<ExtendCollectionsProvider collections={[LOCKED]}>
<span>inside</span>
</ExtendCollectionsProvider>
);
};
const { unmount } = mountWith(app, <Host />);
expect(addCount).toBe(1);
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
unmount();
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
});
// Default semantics: "static-at-mount". Caller's prop changes are ignored.
describe('with syncOnChange={false} (default)', () => {
it('ignores a `collections` prop reference change after mount', () => {
const app = makeApp();
const Host: React.FC = () => {
const [list, setList] = useState<CollectionOptions[]>([LOCKED]);
return (
<>
<button type="button" onClick={() => setList([LOCKED, POSTS])}>
add posts
</button>
<ExtendCollectionsProvider collections={list}>
<span>inside</span>
</ExtendCollectionsProvider>
</>
);
};
const { getByText } = mountWith(app, <Host />);
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
expect(getMain(app).getCollection(POSTS.name)).toBeUndefined();
act(() => {
getByText('add posts').click();
});
// POSTS must NOT have been registered — syncOnChange is off.
expect(getMain(app).getCollection(POSTS.name)).toBeUndefined();
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
});
});
describe('with syncOnChange={true}', () => {
it('adds collections newly added to the prop and removes ones dropped from it', () => {
const app = makeApp();
const Host: React.FC = () => {
const [list, setList] = useState<CollectionOptions[]>([LOCKED, POSTS]);
return (
<>
<button type="button" onClick={() => setList([POSTS, USERS])}>
swap
</button>
<ExtendCollectionsProvider collections={list} syncOnChange>
<span>inside</span>
</ExtendCollectionsProvider>
</>
);
};
const { getByText } = mountWith(app, <Host />);
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
expect(getMain(app).getCollection(POSTS.name)?.name).toBe(POSTS.name);
expect(getMain(app).getCollection(USERS.name)).toBeUndefined();
act(() => {
getByText('swap').click();
});
// LOCKED was dropped from the prop → removed.
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
// POSTS was in both lists → kept.
expect(getMain(app).getCollection(POSTS.name)?.name).toBe(POSTS.name);
// USERS is new → added.
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
});
it('still leaves pre-existing collections it never owned alone after diff', () => {
const app = makeApp();
// Pre-existing in the data source before the provider mounts.
getMain(app).addCollection(USERS);
const Host: React.FC = () => {
const [list, setList] = useState<CollectionOptions[]>([LOCKED, USERS]);
return (
<>
<button type="button" onClick={() => setList([LOCKED])}>
drop users
</button>
<ExtendCollectionsProvider collections={list} syncOnChange>
<span>inside</span>
</ExtendCollectionsProvider>
</>
);
};
const { getByText } = mountWith(app, <Host />);
// USERS was already there, so the provider never owned it.
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
act(() => {
getByText('drop users').click();
});
// The prop dropped USERS, but the provider doesn't own it — must not
// accidentally remove it.
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
});
});
// Mid-session reload: the data source manager wipes everything and reloads
// from the server; client-only entries this provider registered would be
// gone. The `dataSource:loaded` event handler re-adds owned entries.
it('re-registers owned collections after a dataSource:loaded event', () => {
const app = makeApp();
mountWith(
app,
<ExtendCollectionsProvider collections={[LOCKED]}>
<span>inside</span>
</ExtendCollectionsProvider>,
);
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
// Simulate the data source manager wiping then re-broadcasting the event.
act(() => {
getMain(app).removeCollection(LOCKED.name);
app.eventBus.dispatchEvent(new CustomEvent('dataSource:loaded', { detail: { dataSourceKey: 'main' } }));
});
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
});
});
@@ -392,10 +392,15 @@ export default function BasicAuthAdminSettings() {
showIcon
message={t('The authentication allows users to sign in via username or email.')}
/>
{/*
`forceRender: true` 让所有 tab 的内容首屏就 mount —— 否则非 active tab 里的
`Form.Item` 从未注册到父抽屉表单的 value store,提交时会把那些字段当成"用户清空",
覆盖掉服务端已有的值。v1 用 Formily `FormTab` 默认就是全 mount,所以没这个坑。
*/}
<Tabs
items={[
{ key: 'signup', label: t('Sign up settings'), children: <SignUpTab /> },
{ key: 'forgot', label: t('Forgot password'), children: <ForgotPasswordTab /> },
{ key: 'signup', label: t('Sign up settings'), forceRender: true, children: <SignUpTab /> },
{ key: 'forgot', label: t('Forgot password'), forceRender: true, children: <ForgotPasswordTab /> },
]}
/>
</div>
@@ -12,7 +12,7 @@ import { Empty, Space, Spin, Tabs } from 'antd';
import React, { lazy, Suspense, useContext, useMemo } from 'react';
import { AuthenticatorsContext, type Authenticator } from '../authenticator';
import { useDocumentTitle } from '../hooks';
import { useAuthTranslation } from '../locale';
import { useAuthTranslation, useT } from '../locale';
import PluginAuthClientV2, { type AuthOptions } from '../plugin';
type LoaderMap<L> = Record<string, L>;
@@ -44,6 +44,11 @@ function lazyByAuthType<P>(loaderMap: LoaderMap<() => Promise<{ default: React.C
export default function SignInPage() {
const { t } = useAuthTranslation();
// `authTypeTitle` 从服务端来时是 `tval` 生成的原始模板字符串
// `{{t("Password", {"ns":"@nocobase/plugin-auth"})}}`),不展开就会直出到 tab label
// 上。v1 走 `Schema.compile(value, { t })` 展开;v2 用 `useT()`,它内部走
// `flowEngine.context.t`,对纯字符串和模板字符串都安全(无模板时原样返回)。
const compileT = useT();
const authenticators = useContext(AuthenticatorsContext);
const signInFormLoaders = useLoaderMap('signInFormLoader');
const signInButtonLoaders = useLoaderMap('signInButtonLoader');
@@ -60,9 +65,10 @@ export default function SignInPage() {
if (!FormComponent) {
return null;
}
const typeLabel = compileT(authenticator.authTypeTitle || authenticator.authType);
return {
key: authenticator.name,
label: authenticator.title || `${t('Sign-in')} (${authenticator.authTypeTitle || authenticator.authType})`,
label: authenticator.title || `${t('Sign-in')} (${typeLabel})`,
children: (
<Suspense fallback={<Spin />}>
<FormComponent authenticator={authenticator} />
@@ -71,7 +77,7 @@ export default function SignInPage() {
};
})
.filter(Boolean);
}, [authenticators, resolveSignInForm, t]);
}, [authenticators, resolveSignInForm, t, compileT]);
const buttons = useMemo(() => {
return authenticators