换模型时不再拿上一个模型的地址渲染 3D 查看器

- useResolvedAssetReadUrl:地址与「它是为哪个 objectKey 换回来的」一起返回(`resolvedObjectKey`),换签期间归属清空——只按地址字符串判断的话,调用方分不出「新资源 + 旧地址」这一帧
- Model3dViewerModal:只有确认地址属于当前模型才交给查看器;该帧按「正在准备模型地址」提示,不闪「地址不可用」
- 用例:换模型那一帧不渲染查看器;换 objectKey 时旧地址的归属先清空
This commit is contained in:
2026-09-24 21:11:56 +08:00
parent dfbde6fbc1
commit 8e169e17b8
4 changed files with 107 additions and 7 deletions
@@ -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
+13
View File
@@ -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,
};
}