@lark-apaas/coding-template-nestjs-react-fullstack 0.1.52-alpha.20261027082821 → 0.1.52-alpha.20261027094037

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.52-alpha.20261027082821",
3
+ "version": "0.1.52-alpha.20261027094037",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -13,6 +13,18 @@ import {
13
13
  PopoverTrigger,
14
14
  } from '@client/src/components/ui/popover';
15
15
 
16
+ /**
17
+ * 阻止 Popover 打开时的自动聚焦。
18
+ *
19
+ * Radix 的 FocusScope 在弹层挂载时会把焦点给容器里第一个可聚焦元素。人员卡片
20
+ * 挂载那一刻内容就已经在了(飞书名片里有「消息」等按钮),于是一打开就有个
21
+ * 按钮带上焦点环。这是张只读预览卡,焦点应该留在触发器上 —— Radix 自己的
22
+ * HoverCard 也是这么处理的。
23
+ */
24
+ const handleProfileOpenAutoFocus = (event: Event) => {
25
+ event.preventDefault();
26
+ };
27
+
16
28
  export interface IUserDisplayProps {
17
29
  /**
18
30
  * 用户列表
@@ -88,6 +100,7 @@ export const UserDisplay: React.FC<IUserDisplayProps> = ({
88
100
  <PopoverContent
89
101
  align="start"
90
102
  sideOffset={8}
103
+ onOpenAutoFocus={handleProfileOpenAutoFocus}
91
104
  className="w-[320px] border-0 border-border/50 bg-card p-0 shadow-[0px_8px_24px_8px_rgba(31,35,41,0.04),0px_6px_12px_rgba(31,35,41,0.04),0px_4px_8px_-8px_rgba(31,35,41,0.06)]"
92
105
  >
93
106
  <UserProfile value={user} />
@@ -15,7 +15,8 @@
15
15
  */
16
16
 
17
17
  /** 池容量上限,超出按最近使用顺序淘汰 */
18
- let maxEntries = 5;
18
+ /* 暂时放大到 20:观察同时备更多人时的内存与渲染开销,确认后再定最终值 */
19
+ let maxEntries = 20;
19
20
 
20
21
  /**
21
22
  * 调整缓存策略:需要同时备好更多人的卡片时调大 maxEntries。
@@ -2,7 +2,14 @@
2
2
 
3
3
  import { logger } from '@lark-apaas/client-toolkit/logger';
4
4
 
5
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+ import React, {
6
+ useCallback,
7
+ useEffect,
8
+ useId,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
6
13
  import SHA1 from 'crypto-js/sha1';
7
14
 
8
15
  import { getAssetsUrl } from '@client/src/components/business-ui/api/user-profiles/service';
@@ -209,6 +216,42 @@ async function renderLarkProfile(
209
216
  return host.lastElementChild;
210
217
  }
211
218
 
219
+ /**
220
+ * 渲染一张完整可用的名片。
221
+ *
222
+ * `visibleHost` 非空时直接渲进可见容器(首次打开,让用户看到飞书自己的骨架屏);
223
+ * 否则开一个专属离屏插槽 —— 并发渲染共用容器会取错元素。插槽只用来隔离本次
224
+ * 渲染,拿到元素后就把它挪到离屏容器下并撤掉插槽,否则空壳 div 会累积。
225
+ *
226
+ * 放在模块级而不是 effect 内,是为了让「真正卸载时补备用卡」那条路径也能用,
227
+ * 不必把它写进 effect 的闭包、从而被迫放宽依赖。
228
+ */
229
+ async function renderCard(
230
+ param: LarkCardParam,
231
+ visibleHost: HTMLElement | null,
232
+ ): Promise<Element | null> {
233
+ const slot = visibleHost ? null : createOffscreenSlot();
234
+ const live = await renderLarkProfile(param, visibleHost ?? slot!);
235
+
236
+ const settle = () => {
237
+ if (!slot) return;
238
+ if (live && live.parentNode === slot) getOffscreenHost().appendChild(live);
239
+ slot.remove();
240
+ };
241
+
242
+ if (!live) {
243
+ settle();
244
+ return null;
245
+ }
246
+ if (!(await waitForCardReady(live))) {
247
+ live.remove();
248
+ settle();
249
+ return null;
250
+ }
251
+ settle();
252
+ return live;
253
+ }
254
+
212
255
  function UserProfile(props: UserProfileProps) {
213
256
  const { value, userId: originUserId, user_id, accountType = 'apaas' } = props;
214
257
 
@@ -292,30 +335,38 @@ function LarkProfileCard(props: { readonly larkCardParam: LarkCardParam }) {
292
335
  const { larkCardParam } = props;
293
336
  const { needRedirect, redirectURL, targetLarkOpenID } = larkCardParam;
294
337
 
338
+ // 「打开后更新」渲染出的元素由本实例独占 —— 它会被 attach 进本实例的容器,
339
+ // 取消时也由本实例回收。所以去重必须按实例隔离:ensureRender 的 key 是模块级的,
340
+ // 同一个人的两个实例共享一次渲染的话,两边会拿到同一个 DOM 元素,
341
+ // 后挂载的那个会把节点从前一个容器里抢走(前者变空白),先卸载的那个
342
+ // 还会把另一个正在显示的节点 remove 掉。
343
+ //
344
+ // 备用卡池仍然按人共享 —— 那条路径是 takeSpare 独占领取,不存在同一元素
345
+ // 被两个实例同时持有的问题。
346
+ const instanceId = useId();
347
+
295
348
  const hostRef = useRef<HTMLDivElement>(null);
296
349
  // 初值在渲染期就定下来:有备用卡就不该出现转圈。effect 在首次绘制之后
297
350
  // 才跑,若初值恒为 true,命中缓存也会先闪一帧 loading。
298
351
  const [showSpinner, setShowSpinner] = useState(() =>
299
- !hasSpare(larkCardParam.targetLarkOpenID),
352
+ !hasSpare(targetLarkOpenID),
300
353
  );
301
-
302
- // 这次挂载取到的备用卡。effect 可能在一次打开中跑多次(依赖变化、
303
- // React 开发期双调用),而 takeSpare 是「取走」语义 —— 不记在 ref 里的话,
304
- // 第二次就拿不到,会误判成没有缓存而走完整 loading。
305
- const spareRef = useRef<Element | null>(null);
306
-
307
- // 渲染失败(含就绪超时)。只在「屏幕上还什么都没有」时才需要告诉用户,
308
- // 已经挂着备用卡的话保持现状即可。
309
354
  const [renderFailed, setRenderFailed] = useState(false);
310
355
  const [retryToken, setRetryToken] = useState(0);
311
356
 
312
- // 挂上卡片那一刻、**本人那一屏**的文字。关闭时拿它和当前值比,就知道用户
313
- // 有没有改过本人的数据(最典型的是编辑自己的个性签名)。只看本人那一屏,
314
- // 是为了不把「点进上级的名片」这种纯导航误判成数据变化。
315
- //
316
- // 这是「同一个元素的前后两个时刻」比对,不是比两张独立渲染的卡 —— 后者
317
- // 会被 CDN 分片轮换、图片懒加载时序等噪声污染,前者不会。
357
+ // 这次挂载取到的备用卡。takeSpare 是「取走」语义,effect 可能重复执行,
358
+ // 不记在 ref 里第二次就拿不到,会误判成没有缓存而走完整 loading。
359
+ const spareRef = useRef<Element | null>(null);
360
+ // 当前挂在容器里的那一张。卸载清理要用它,而那时 hostRef 可能已被置空。
361
+ const shownRef = useRef<Element | null>(null);
362
+ // 挂上那一刻、本人那一屏的文字。卸载时比对,判断用户有没有改过本人的数据。
318
363
  const attachedTextRef = useRef<string>('');
364
+ // 组件是否还活着。异步回调的归属是「组件实例」而不是「某一次 effect 执行」,
365
+ // 所以用它而不是 effect 内的 cancelled 标志 —— 否则依赖一变化,正在途中的
366
+ // 渲染结果就会被当成已取消而丢弃。
367
+ const aliveRef = useRef(true);
368
+ // 已经为哪一轮 retryToken 启动过渲染,保证依赖变化时不重复发起。
369
+ const startedRef = useRef(-1);
319
370
 
320
371
  const scriptOptions = useMemo(
321
372
  () => ({ onloadCallback: registerLarkAuthHandler }),
@@ -323,6 +374,13 @@ function LarkProfileCard(props: { readonly larkCardParam: LarkCardParam }) {
323
374
  );
324
375
  const scriptStatus = useExternalScript(LARK_SDK_URL, scriptOptions);
325
376
 
377
+ // 卸载清理需要的值走 ref,避免为了它们把依赖写宽
378
+ const teardownRef = useRef({ param: larkCardParam, scriptReady: false });
379
+ teardownRef.current = {
380
+ param: larkCardParam,
381
+ scriptReady: scriptStatus === 'ready',
382
+ };
383
+
326
384
  // 需要走鉴权重定向时整页跳转,不渲染名片
327
385
  useEffect(() => {
328
386
  if (needRedirect && redirectURL) {
@@ -330,6 +388,52 @@ function LarkProfileCard(props: { readonly larkCardParam: LarkCardParam }) {
330
388
  }
331
389
  }, [needRedirect, redirectURL]);
332
390
 
391
+ /**
392
+ * 只在**真正卸载**时回收。
393
+ *
394
+ * 不能放进主 effect 的 cleanup —— 那个 cleanup 在任何依赖变化时都会跑
395
+ * (scriptStatus 从 loading 变 ready、react-query 刷新导致 larkCardParam
396
+ * 换引用,都是必经路径)。在那里 remove 掉正在显示的卡片,会造成可见的
397
+ * 闪白回退,而且被摘掉的若是「打开后更新」那张,它既不在池里也不在容器里,
398
+ * 直接成了孤儿。
399
+ */
400
+ useEffect(() => {
401
+ aliveRef.current = true;
402
+ return () => {
403
+ aliveRef.current = false;
404
+
405
+ const shown = shownRef.current;
406
+ shownRef.current = null;
407
+ if (!shown) return;
408
+
409
+ const changed = ownProfileText(shown) !== attachedTextRef.current;
410
+ // 用过的那张直接丢弃 —— 它可能被滚动过、点进过别人的名片,
411
+ // 而这些状态有一半在飞书 React 内部,无法可靠恢复
412
+ shown.remove();
413
+
414
+ const { param, scriptReady } = teardownRef.current;
415
+ if (!changed || !scriptReady) return;
416
+
417
+ // 用户在卡片里改过本人的数据(最典型的是编辑个性签名)。已经备好的那张
418
+ // 是改动之前渲染的,内容已过时:丢掉,并立刻重新备一张 —— 只丢不补的话
419
+ // 下次打开会退回完整 loading。带时间戳的 key 绕开去重,确保拿到的是
420
+ // 改动之后重新渲染的结果。
421
+ dropSpare(param.targetLarkOpenID);
422
+ void ensureRender(
423
+ `${param.targetLarkOpenID}:spare:${String(Date.now())}`,
424
+ async () => {
425
+ const live = await renderCard(param, null);
426
+ if (live) putSpare(param.targetLarkOpenID, live);
427
+ return live;
428
+ },
429
+ ).catch((error: unknown) => {
430
+ logger.info('re-prepare spare lark profile failed', error);
431
+ });
432
+ };
433
+ }, []);
434
+
435
+ // 主逻辑。对重复执行幂等:已经挂了东西就不再顶备用卡,已经发起过的渲染
436
+ // 不重复发起。
333
437
  useEffect(() => {
334
438
  if (needRedirect) return;
335
439
 
@@ -337,145 +441,77 @@ function LarkProfileCard(props: { readonly larkCardParam: LarkCardParam }) {
337
441
  if (!host) return;
338
442
 
339
443
  currentRedirectURL = redirectURL ?? '';
340
-
341
444
  // 脚本已存在时 useExternalScript 会跳过 onloadCallback,这里补注册一次
342
445
  if (scriptStatus === 'ready') registerLarkAuthHandler();
343
446
 
344
- let cancelled = false;
345
-
346
- /**
347
- * 把元素挂到当前容器,并移除容器里原有的那张。
348
- * 用 replaceChildren 而不是 appendChild —— 后台换新时新旧两张都存在,
349
- * 必须显式只留一张,不能依赖 putCard 内部清理旧元素的顺序。
350
- * effect 已失效则什么都不做,元素留在池里等下次打开。
351
- */
352
447
  const attach = (el: Element | null) => {
353
- if (cancelled || !el) return;
448
+ if (!aliveRef.current || !el) return;
354
449
  host.replaceChildren(el);
450
+ shownRef.current = el;
355
451
  attachedTextRef.current = ownProfileText(el);
356
452
  setShowSpinner(false);
453
+ setRenderFailed(false);
357
454
  };
358
455
 
359
- /**
360
- * 渲染一张完整可用的名片。`visible` 为 true 时直接渲进可见容器(首次打开,
361
- * 让用户看到飞书自己的骨架屏),否则开一个专属离屏插槽 —— 并发渲染共用
362
- * 容器会取错元素。渲染不稳定(就绪超时)则判失败。
363
- */
364
- const renderInto = async (visible: boolean) => {
365
- const slot = visible ? null : createOffscreenSlot();
366
- const live = await renderLarkProfile(larkCardParam, slot ?? host);
367
- // 插槽只用来隔离本次渲染,拿到元素后就把它挪到离屏容器下并撤掉插槽,
368
- // 否则空壳 div 会一直累积
369
- const settle = () => {
370
- if (!slot) return;
371
- if (live && live.parentNode === slot) getOffscreenHost().appendChild(live);
372
- slot.remove();
373
- };
374
- if (!live) {
375
- settle();
376
- return null;
377
- }
378
- if (!(await waitForCardReady(live))) {
379
- live.remove();
380
- settle();
381
- return null;
382
- }
383
- settle();
384
- return live;
385
- };
386
-
387
- spareRef.current ??= takeSpare(targetLarkOpenID);
388
- const spare = spareRef.current;
389
-
390
- if (spare) {
391
- // 备用卡是渲染好之后一直停在离屏、从没显示过的,所以必然是初始态,
392
- // 而且它是活元素 —— 挂上即可交互,不是静态快照
393
- attach(spare);
394
- } else {
395
- setShowSpinner(true);
456
+ if (!shownRef.current) {
457
+ // 备用卡是渲染好后一直停在离屏、从没显示过的,所以必然是初始态,
458
+ // 而且是活元素 —— 挂上即可交互,不是静态快照
459
+ spareRef.current ??= takeSpare(targetLarkOpenID);
460
+ if (spareRef.current) attach(spareRef.current);
396
461
  }
397
462
 
398
- if (scriptStatus === 'ready') {
399
- // 打开后更新一次:再渲染一张,就绪后换上去,让这次看到的是最新数据。
400
- // 有备用卡顶着就离屏渲染(用户看不到骨架屏),没有就直接渲进可见容器。
401
- void ensureRender(`${targetLarkOpenID}:update`, () => renderInto(!spare))
402
- .then((live) => {
403
- if (cancelled) {
404
- // 组件已卸载(用户在新卡渲染完成前关掉了弹窗)。此时元素已经被
405
- // renderInto 挪进常驻的离屏容器,既不会被 attach,也不进受
406
- // maxEntries 约束的备用卡池 —— 放着不管就会随着反复开关一直累积。
407
- live?.remove();
408
- return;
409
- }
410
- if (live) {
411
- attach(live);
412
- return;
413
- }
414
- // 渲染没能就绪。屏幕上已经挂着备用卡就保持现状;什么都没有的话
415
- // 必须收掉 loading 并给出重试入口,否则会一直转圈。
416
- if (!host.firstElementChild) {
417
- setShowSpinner(false);
418
- setRenderFailed(true);
419
- }
420
- })
421
- .catch((error: unknown) => {
422
- logger.info('update lark profile failed', error);
423
- if (cancelled) return;
463
+ if (scriptStatus !== 'ready') return;
464
+ if (startedRef.current === retryToken) return;
465
+ startedRef.current = retryToken;
466
+
467
+ const hadSpare = Boolean(shownRef.current);
468
+ if (!hadSpare) setShowSpinner(true);
469
+
470
+ // 打开后更新一次:再渲染一张,就绪后换上去,让这次看到的是最新数据。
471
+ // 有备用卡顶着就离屏渲染(用户看不到骨架屏),没有就直接渲进可见容器。
472
+ void ensureRender(
473
+ `${targetLarkOpenID}:update:${instanceId}:${String(retryToken)}`,
474
+ () => renderCard(larkCardParam, hadSpare ? null : host),
475
+ )
476
+ .then((live) => {
477
+ if (!aliveRef.current) {
478
+ // 组件已卸载。元素此时在常驻离屏容器里,既不会被 attach 也不进
479
+ // 受 maxEntries 约束的池 —— 放着不管会随反复开关一直累积。
480
+ live?.remove();
481
+ return;
482
+ }
483
+ if (live) {
484
+ attach(live);
485
+ return;
486
+ }
487
+ // 渲染没能就绪。已经挂着备用卡就保持现状;什么都没有的话必须收掉
488
+ // loading 并给出重试入口,否则会一直转圈。
489
+ if (!shownRef.current) {
424
490
  setShowSpinner(false);
425
- if (!host.firstElementChild) setRenderFailed(true);
426
- });
427
-
428
- // 立刻开始备下一张。
429
- //
430
- // 这件事必须在「打开」时做,不能等到关闭 —— 真实环境里渲染一张要走
431
- // 鉴权、render、飞书取数、图片加载,几秒起步。放在关闭时做的话,用户
432
- // 稍微点快一点,下次打开就还没备好,又掉回完整 loading。放在打开时做,
433
- // 它有「整个浏览时长 + 关闭后的间隔」来完成。
434
- //
435
- // 总开销不变,仍是一个开关周期两次渲染,只是把第二次提前了。
436
- if (!hasSpare(targetLarkOpenID)) {
437
- void ensureRender(`${targetLarkOpenID}:spare`, async () => {
438
- const live = await renderInto(false);
439
- if (live) putSpare(targetLarkOpenID, live);
440
- return live;
441
- }).catch((error: unknown) => {
442
- logger.info('prepare spare lark profile failed', error);
443
- });
444
- }
445
- }
446
-
447
- return () => {
448
- cancelled = true;
449
-
450
- const shown = host.firstElementChild;
451
- const changed =
452
- !!shown && ownProfileText(shown) !== attachedTextRef.current;
491
+ setRenderFailed(true);
492
+ }
493
+ })
494
+ .catch((error: unknown) => {
495
+ logger.info('update lark profile failed', error);
496
+ if (!aliveRef.current) return;
497
+ setShowSpinner(false);
498
+ if (!shownRef.current) setRenderFailed(true);
499
+ });
453
500
 
454
- // 用过的那张直接丢弃 —— 它可能被滚动过、点进过别人的名片,
455
- // 而这些状态有一半在飞书 React 内部,我们无法可靠地恢复
456
- shown?.remove();
457
-
458
- if (!changed || scriptStatus !== 'ready') return;
459
-
460
- // 用户在卡片里改过东西(最典型的是编辑自己的个性签名)。已经备好的
461
- // 那张是改动之前渲染的,内容已经过时。
462
- //
463
- // 丢掉它,并**立刻重新备一张** —— 只丢不补的话,下次打开就退回完整
464
- // loading,而「打开秒出」正是这套机制存在的理由。用带时间戳的 key
465
- // 绕开去重,确保拿到的是改动之后重新渲染的结果。
466
- dropSpare(targetLarkOpenID);
467
- void ensureRender(
468
- `${targetLarkOpenID}:spare:${String(Date.now())}`,
469
- async () => {
470
- const live = await renderInto(false);
471
- if (live) putSpare(targetLarkOpenID, live);
472
- return live;
473
- },
474
- ).catch((error: unknown) => {
475
- logger.info('re-prepare spare lark profile failed', error);
501
+ // 立刻开始备下一张。必须在「打开」时做而不是「关闭」时 —— 真实环境渲染
502
+ // 一张要走鉴权、render、网关取数、图片加载,几秒起步;放在关闭时做的话,
503
+ // 用户点快一点下次打开就还没备好,又掉回完整 loading。
504
+ if (!hasSpare(targetLarkOpenID)) {
505
+ void ensureRender(`${targetLarkOpenID}:spare`, async () => {
506
+ const live = await renderCard(larkCardParam, null);
507
+ if (live) putSpare(targetLarkOpenID, live);
508
+ return live;
509
+ }).catch((error: unknown) => {
510
+ logger.info('prepare spare lark profile failed', error);
476
511
  });
477
- };
512
+ }
478
513
  }, [
514
+ instanceId,
479
515
  larkCardParam,
480
516
  needRedirect,
481
517
  redirectURL,
@@ -16,6 +16,18 @@ import {
16
16
  PopoverTrigger,
17
17
  } from '@client/src/components/ui/popover';
18
18
 
19
+ /**
20
+ * 阻止 Popover 打开时的自动聚焦。
21
+ *
22
+ * Radix 的 FocusScope 在弹层挂载时会把焦点给容器里第一个可聚焦元素。人员卡片
23
+ * 挂载那一刻内容就已经在了(飞书名片里有「消息」等按钮),于是一打开就有个
24
+ * 按钮带上焦点环。这是张只读预览卡,焦点应该留在触发器上 —— Radix 自己的
25
+ * HoverCard 也是这么处理的。
26
+ */
27
+ const handleProfileOpenAutoFocus = (event: Event) => {
28
+ event.preventDefault();
29
+ };
30
+
19
31
  export type UserPillProps = {
20
32
  /**
21
33
  * 用户 ID,用于 UserProfile 展示
@@ -117,6 +129,7 @@ export const UserPill: React.FC<UserPillProps> = ({
117
129
  side="bottom"
118
130
  align="start"
119
131
  sideOffset={8}
132
+ onOpenAutoFocus={handleProfileOpenAutoFocus}
120
133
  className="w-80 p-0"
121
134
  >
122
135
  <UserProfile userId={userId} accountType={accountType} />