换模型时不再拿上一个模型的地址渲染 3D 查看器
- useResolvedAssetReadUrl:地址与「它是为哪个 objectKey 换回来的」一起返回(`resolvedObjectKey`),换签期间归属清空——只按地址字符串判断的话,调用方分不出「新资源 + 旧地址」这一帧 - Model3dViewerModal:只有确认地址属于当前模型才交给查看器;该帧按「正在准备模型地址」提示,不闪「地址不可用」 - 用例:换模型那一帧不渲染查看器;换 objectKey 时旧地址的归属先清空
This commit is contained in:
@@ -65,6 +65,7 @@ describe('Model3dViewerModal', () => {
|
||||
resolvedUrl: source,
|
||||
isResolving: false,
|
||||
shouldResolve: Boolean(source),
|
||||
resolvedObjectKey: source,
|
||||
}));
|
||||
// 最小替身:透出「有没有传 format」、拿得到 resetView 句柄、并按用例上报状态。
|
||||
model3dViewerMock.mockImplementation(
|
||||
@@ -159,6 +160,7 @@ describe('Model3dViewerModal', () => {
|
||||
resolvedUrl: '',
|
||||
isResolving: true,
|
||||
shouldResolve: true,
|
||||
resolvedObjectKey: '',
|
||||
}));
|
||||
|
||||
renderModal(createLayer());
|
||||
@@ -167,6 +169,26 @@ describe('Model3dViewerModal', () => {
|
||||
expect(screen.queryByTestId('model3d-viewer')).toBeNull();
|
||||
});
|
||||
|
||||
it('换模型的那一帧不拿上一个模型的地址渲染查看器', () => {
|
||||
// 钩子先返回旧模型的地址、下一轮才换成新模型的:地址字符串没变,查看器不会重跑加载。
|
||||
useResolvedAssetReadUrlMock.mockImplementation(() => ({
|
||||
resolvedUrl: 'https://signed.example.com/old-model.glb',
|
||||
isResolving: false,
|
||||
shouldResolve: true,
|
||||
resolvedObjectKey: 'editor/model3d/old-task/model.glb',
|
||||
}));
|
||||
|
||||
renderModal(
|
||||
createLayer({
|
||||
objectKey: 'editor/model3d/new-task/model.glb',
|
||||
resourceId: 'resource-3d-new',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('model3d-viewer')).toBeNull();
|
||||
expect(screen.getByRole('status').textContent).toBe('正在准备模型地址…');
|
||||
});
|
||||
|
||||
it('重置视角只在查看器就绪后可点', () => {
|
||||
renderModal(createLayer());
|
||||
|
||||
|
||||
@@ -76,10 +76,16 @@ export function Model3dViewerModal({
|
||||
onClose,
|
||||
}: Model3dViewerModalProps) {
|
||||
const modelObjectKey = layer?.objectKey?.trim() ?? '';
|
||||
const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(modelObjectKey, {
|
||||
objectKey: layer?.objectKey,
|
||||
refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`,
|
||||
});
|
||||
const { resolvedUrl, isResolving, resolvedObjectKey } =
|
||||
useResolvedAssetReadUrl(modelObjectKey, {
|
||||
objectKey: layer?.objectKey,
|
||||
refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`,
|
||||
});
|
||||
// 取地址是异步的:换模型的那一帧里,钩子手上还是上一个模型的地址。地址字符串没变时
|
||||
// 查看器不会重跑加载,屏幕上会继续显示上一个模型(评审 #85),所以只有确认这个地址
|
||||
// 确实属于当前模型(钩子按 objectKey 记着它换签时用的是哪一个)才把地址交给查看器。
|
||||
const modelUrl =
|
||||
modelObjectKey && resolvedObjectKey === modelObjectKey ? resolvedUrl : '';
|
||||
const viewerRef = useRef<Model3DViewerHandle>(null);
|
||||
const layerIdentity = layer?.id ?? null;
|
||||
// 查看器状态连同"它属于哪个模型"一起存:换了模型,旧状态自动失效。
|
||||
@@ -92,7 +98,7 @@ export function Model3dViewerModal({
|
||||
|
||||
const modelSource = resolveModel3dViewerSource({
|
||||
objectKey: modelObjectKey,
|
||||
resolvedUrl,
|
||||
resolvedUrl: modelUrl,
|
||||
});
|
||||
const viewerIdentity = modelSource
|
||||
? `${layerIdentity ?? ''}:${modelSource.url}`
|
||||
@@ -101,10 +107,15 @@ export function Model3dViewerModal({
|
||||
Boolean(viewerIdentity) &&
|
||||
viewerStatus?.identity === viewerIdentity &&
|
||||
viewerStatus.status === 'ready';
|
||||
// 「手上有个地址,但它还不是当前模型的」这一帧同样属于准备中:否则会闪一句「地址不可用」。
|
||||
const isModelUrlPending =
|
||||
Boolean(modelObjectKey) &&
|
||||
!modelUrl &&
|
||||
(isResolving || Boolean(resolvedUrl));
|
||||
const hintText = resolveModel3dViewerHint({
|
||||
hasModelSource: Boolean(modelObjectKey),
|
||||
isResolving,
|
||||
resolvedUrl,
|
||||
isResolving: isModelUrlPending,
|
||||
resolvedUrl: modelUrl,
|
||||
});
|
||||
const errorText = resolveModel3dViewerFailureText(
|
||||
viewerStatus?.identity === viewerIdentity ? viewerStatus.detail : null,
|
||||
|
||||
@@ -372,6 +372,60 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
).toBe('/creation-type-references/puzzle.webp');
|
||||
});
|
||||
|
||||
test('换 objectKey 时旧地址的归属先清空,不会冒充新资源', async () => {
|
||||
const signedResponse = (signedUrl: string) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'oss/first.png',
|
||||
signedUrl,
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
// 第二个 objectKey 的换签一直挂起:这正是「新资源 + 旧地址」同时存在的那一帧。
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
signedResponse('https://signed.example.com/first.png'),
|
||||
)
|
||||
.mockImplementationOnce(() => new Promise<Response>(() => {}));
|
||||
|
||||
function ObjectKeyProbe({ objectKey }: { objectKey: string }) {
|
||||
const { resolvedUrl, resolvedObjectKey } = useResolvedAssetReadUrl(null, {
|
||||
objectKey,
|
||||
});
|
||||
return (
|
||||
<output aria-label="资源地址">{`${resolvedObjectKey}|${resolvedUrl}`}</output>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(<ObjectKeyProbe objectKey="oss/first.png" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('资源地址').textContent).toBe(
|
||||
'oss/first.png|https://signed.example.com/first.png',
|
||||
);
|
||||
});
|
||||
|
||||
rerender(<ObjectKeyProbe objectKey="oss/second.png" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('资源地址').textContent).toBe('|');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('普通封面加载失败时切换到显式 fallbackSrc', async () => {
|
||||
render(
|
||||
<ResolvedAssetImage
|
||||
|
||||
@@ -28,17 +28,24 @@ export function useResolvedAssetReadUrl(
|
||||
shouldResolve ? '' : normalizedSource,
|
||||
);
|
||||
const [isResolving, setIsResolving] = useState(shouldResolve);
|
||||
// 地址和「这个地址是为哪个 objectKey 换回来的」一起存:换签是异步的,而调用方在一帧之内
|
||||
// 可能同时拿到「新资源」和「上一个资源的地址」。只按地址字符串判断的话,查看器这类以地址
|
||||
// 为加载键的组件不会重跑加载,会继续显示上一个资源(评审 #85)。
|
||||
const [resolvedObjectKey, setResolvedObjectKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedSource && !normalizedObjectKey) {
|
||||
setResolvedUrl('');
|
||||
setIsResolving(false);
|
||||
setResolvedObjectKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldResolve) {
|
||||
setResolvedUrl(normalizedSource);
|
||||
setIsResolving(false);
|
||||
// 这一步没有换签,地址就是入参本身,不对应任何 objectKey。
|
||||
setResolvedObjectKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,6 +53,8 @@ export function useResolvedAssetReadUrl(
|
||||
// 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。
|
||||
setResolvedUrl('');
|
||||
setIsResolving(true);
|
||||
// 换签期间清掉归属:调用方据此知道手上的旧地址已经不是当前资源的。
|
||||
setResolvedObjectKey('');
|
||||
|
||||
const resolvePromise = normalizedObjectKey
|
||||
? getSignedAssetReadUrl(
|
||||
@@ -67,12 +76,14 @@ export function useResolvedAssetReadUrl(
|
||||
.then((nextUrl) => {
|
||||
if (!cancelled) {
|
||||
setResolvedUrl(nextUrl);
|
||||
setResolvedObjectKey(normalizedObjectKey);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。
|
||||
setResolvedUrl('');
|
||||
setResolvedObjectKey('');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -96,5 +107,7 @@ export function useResolvedAssetReadUrl(
|
||||
resolvedUrl,
|
||||
isResolving,
|
||||
shouldResolve,
|
||||
/** `resolvedUrl` 对应的 objectKey;地址与当前资源不匹配或未就绪时为空串。 */
|
||||
resolvedObjectKey,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user