@douyinfe/semi-foundation 2.91.0 → 2.92.1

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.
@@ -90,12 +90,50 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
90
90
  this._adapter.focusEditor();
91
91
  }
92
92
 
93
+ /**
94
+ * Delete uploaded file item.
95
+ * Notes:
96
+ * - AIChatInput uses custom upload list UI, so we need to align remove behavior with Upload:
97
+ * 1) respect uploadProps.beforeRemove (support Promise)
98
+ * 2) call uploadProps.onRemove with (currentFile, nextFileList, currentFileItem)
99
+ * 3) still trigger onUploadChange/uploadProps.onChange for fileList update
100
+ */
93
101
  handleUploadFileDelete = (attachment: Attachment) => {
94
- const { attachments } = this.getStates();
95
- const newAttachments = attachments.filter(item => item.uid !== attachment.uid);
96
- this.onUploadChange({
97
- currentFile: attachment,
98
- fileList: newAttachments
102
+ const { uploadProps } = this.getProps();
103
+ const { attachments: attachmentsFromState } = this.getStates();
104
+ const attachments = Array.isArray(attachmentsFromState) ? attachmentsFromState : [];
105
+
106
+ // Keep consistent with Upload: disabled means no-op
107
+ if (uploadProps?.disabled) {
108
+ return;
109
+ }
110
+
111
+ const index = attachments.findIndex(item => item.uid === attachment.uid);
112
+ if (index < 0) {
113
+ return;
114
+ }
115
+
116
+ const beforeRemove = uploadProps?.beforeRemove;
117
+ Promise.resolve(beforeRemove?.(attachment, attachments) ?? true).then(res => {
118
+ // prevent remove while user return false
119
+ if (res === false) {
120
+ return;
121
+ }
122
+
123
+ const newAttachments = attachments.slice();
124
+ newAttachments.splice(index, 1);
125
+
126
+ // Align Upload onRemove signature: (File, nextFileList, currentFileItem)
127
+ // currentFile: use original File instance when available
128
+ uploadProps?.onRemove?.(attachment.fileInstance as any, newAttachments as any, attachment as any);
129
+
130
+ // keep existing behavior: update state + notify upload change
131
+ this.onUploadChange({
132
+ currentFile: attachment,
133
+ fileList: newAttachments
134
+ });
135
+ }).catch(() => {
136
+ // if user pass reject promise, no need to do anything
99
137
  });
100
138
  }
101
139
 
@@ -36,6 +36,14 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
36
36
  changeTemplateVisible: (value: boolean) => void;
37
37
  handlePaste: (files: File[]) => void;
38
38
  handleSuggestionSelect: (suggestion: Suggestion) => void;
39
+ /**
40
+ * Delete uploaded file item.
41
+ * Notes:
42
+ * - AIChatInput uses custom upload list UI, so we need to align remove behavior with Upload:
43
+ * 1) respect uploadProps.beforeRemove (support Promise)
44
+ * 2) call uploadProps.onRemove with (currentFile, nextFileList, currentFileItem)
45
+ * 3) still trigger onUploadChange/uploadProps.onChange for fileList update
46
+ */
39
47
  handleUploadFileDelete: (attachment: Attachment) => void;
40
48
  handleReferenceDelete: (reference: Reference) => void;
41
49
  handleReferenceClick: (reference: Reference) => void;
@@ -42,14 +42,50 @@ class AIChatInputFoundation extends _foundation.default {
42
42
  this._adapter.setContent(suggestion);
43
43
  this._adapter.focusEditor();
44
44
  };
45
+ /**
46
+ * Delete uploaded file item.
47
+ * Notes:
48
+ * - AIChatInput uses custom upload list UI, so we need to align remove behavior with Upload:
49
+ * 1) respect uploadProps.beforeRemove (support Promise)
50
+ * 2) call uploadProps.onRemove with (currentFile, nextFileList, currentFileItem)
51
+ * 3) still trigger onUploadChange/uploadProps.onChange for fileList update
52
+ */
45
53
  this.handleUploadFileDelete = attachment => {
54
+ var _a;
46
55
  const {
47
- attachments
56
+ uploadProps
57
+ } = this.getProps();
58
+ const {
59
+ attachments: attachmentsFromState
48
60
  } = this.getStates();
49
- const newAttachments = attachments.filter(item => item.uid !== attachment.uid);
50
- this.onUploadChange({
51
- currentFile: attachment,
52
- fileList: newAttachments
61
+ const attachments = Array.isArray(attachmentsFromState) ? attachmentsFromState : [];
62
+ // Keep consistent with Upload: disabled means no-op
63
+ if (uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.disabled) {
64
+ return;
65
+ }
66
+ const index = attachments.findIndex(item => item.uid === attachment.uid);
67
+ if (index < 0) {
68
+ return;
69
+ }
70
+ const beforeRemove = uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.beforeRemove;
71
+ Promise.resolve((_a = beforeRemove === null || beforeRemove === void 0 ? void 0 : beforeRemove(attachment, attachments)) !== null && _a !== void 0 ? _a : true).then(res => {
72
+ var _a;
73
+ // prevent remove while user return false
74
+ if (res === false) {
75
+ return;
76
+ }
77
+ const newAttachments = attachments.slice();
78
+ newAttachments.splice(index, 1);
79
+ // Align Upload onRemove signature: (File, nextFileList, currentFileItem)
80
+ // currentFile: use original File instance when available
81
+ (_a = uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.onRemove) === null || _a === void 0 ? void 0 : _a.call(uploadProps, attachment.fileInstance, newAttachments, attachment);
82
+ // keep existing behavior: update state + notify upload change
83
+ this.onUploadChange({
84
+ currentFile: attachment,
85
+ fileList: newAttachments
86
+ });
87
+ }).catch(() => {
88
+ // if user pass reject promise, no need to do anything
53
89
  });
54
90
  };
55
91
  this.handleReferenceDelete = reference => {
@@ -81,9 +81,29 @@ export interface UploadAdapter<P = Record<string, any>, S = Record<string, any>>
81
81
  }
82
82
  declare class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<UploadAdapter<P, S>, P, S> {
83
83
  destroyState: boolean;
84
+ /**
85
+ * Canonical storage for objectURL created by this Upload instance.
86
+ * Do NOT rely on React state localUrls because setState is async and may lose updates
87
+ * when _createURL is called multiple times in a sync loop.
88
+ */
89
+ _localUrls: Record<string, string>;
84
90
  constructor(adapter: UploadAdapter<P, S>);
85
91
  init(): void;
92
+ /**
93
+ * Sync internal objectURL map from current fileList.
94
+ * Only track blob: url with an in-memory File instance.
95
+ */
96
+ _syncLocalUrlsFromFileList(): void;
86
97
  destroy(): void;
98
+ /**
99
+ * Release objectURL created for a specific file uid
100
+ */
101
+ _releaseFileUrl(uid: string): void;
102
+ /**
103
+ * Release all objectURL created by this Upload instance.
104
+ * Only call this when files are truly removed (e.g. clear).
105
+ */
106
+ _releaseAllFileUrls(): void;
87
107
  getError({ action, xhr, message, fileName }: {
88
108
  action: string;
89
109
  xhr: XMLHttpRequest;
@@ -156,7 +176,7 @@ declare class UploadFoundation<P = Record<string, any>, S = Record<string, any>>
156
176
  fileInstance: CustomFile;
157
177
  }): void;
158
178
  handleClear(): void;
159
- _createURL(fileInstance: CustomFile): string;
179
+ _createURL(fileInstance: CustomFile, uid: string): string;
160
180
  releaseMemory(): void;
161
181
  _releaseBlob(url: string): void;
162
182
  isImage(file: CustomFile): boolean;
@@ -51,10 +51,19 @@ class UploadFoundation extends _foundation.default {
51
51
  constructor(adapter) {
52
52
  super(Object.assign({}, adapter));
53
53
  this.destroyState = false;
54
+ /**
55
+ * Canonical storage for objectURL created by this Upload instance.
56
+ * Do NOT rely on React state localUrls because setState is async and may lose updates
57
+ * when _createURL is called multiple times in a sync loop.
58
+ */
59
+ this._localUrls = {};
54
60
  }
55
61
  init() {
56
62
  // make sure state reset, otherwise may cause upload abort in React StrictMode, like https://github.com/DouyinFE/semi-design/pull/843
57
63
  this.destroyState = false;
64
+ // In controlled mode, parent may keep and pass back fileList with blob url.
65
+ // Sync them into internal map so later remove/clear can revoke correctly.
66
+ this._syncLocalUrlsFromFileList();
58
67
  const {
59
68
  disabled,
60
69
  addOnPasting
@@ -63,17 +72,81 @@ class UploadFoundation extends _foundation.default {
63
72
  this.bindPastingHandler();
64
73
  }
65
74
  }
75
+ /**
76
+ * Sync internal objectURL map from current fileList.
77
+ * Only track blob: url with an in-memory File instance.
78
+ */
79
+ _syncLocalUrlsFromFileList() {
80
+ const {
81
+ fileList
82
+ } = this.getStates();
83
+ const next = {};
84
+ if (!Array.isArray(fileList)) {
85
+ this._localUrls = {};
86
+ this._adapter.updateLocalUrls([]);
87
+ return;
88
+ }
89
+ fileList.forEach(item => {
90
+ const uid = item && item.uid;
91
+ const url = item && item.url;
92
+ const fileInstance = item && item.fileInstance;
93
+ const hasFileCtor = typeof File !== 'undefined';
94
+ const isFile = hasFileCtor && fileInstance instanceof File;
95
+ if (uid && typeof url === 'string' && url.startsWith('blob:') && isFile) {
96
+ next[uid] = url;
97
+ }
98
+ });
99
+ this._localUrls = next;
100
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
101
+ }
66
102
  destroy() {
67
103
  const {
68
104
  disabled,
69
105
  addOnPasting
70
106
  } = this.getProps();
71
- this.releaseMemory();
107
+ // Do NOT revoke objectURL on unmount.
108
+ // In controlled mode, parent may keep and pass back fileList with blob url;
109
+ // revoking here will cause preview/blob ERR_FILE_NOT_FOUND after remount.
72
110
  if (!disabled) {
73
111
  this.unbindPastingHandler();
74
112
  }
75
113
  this.destroyState = true;
76
114
  }
115
+ /**
116
+ * Release objectURL created for a specific file uid
117
+ */
118
+ _releaseFileUrl(uid) {
119
+ if (!uid) {
120
+ return;
121
+ }
122
+ const url = this._localUrls && this._localUrls[uid];
123
+ if (!url) {
124
+ return;
125
+ }
126
+ this._releaseBlob(url);
127
+ const next = Object.assign({}, this._localUrls || {});
128
+ delete next[uid];
129
+ this._localUrls = next;
130
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
131
+ }
132
+ /**
133
+ * Release all objectURL created by this Upload instance.
134
+ * Only call this when files are truly removed (e.g. clear).
135
+ */
136
+ _releaseAllFileUrls() {
137
+ const localUrls = this._localUrls;
138
+ if (!localUrls || typeof localUrls !== 'object') {
139
+ return;
140
+ }
141
+ Object.keys(localUrls).forEach(uid => {
142
+ const url = localUrls[uid];
143
+ if (url) {
144
+ this._releaseBlob(url);
145
+ }
146
+ });
147
+ this._localUrls = {};
148
+ this._adapter.updateLocalUrls([]);
149
+ }
77
150
  getError(_ref) {
78
151
  let {
79
152
  action,
@@ -243,6 +316,11 @@ class UploadFoundation extends _foundation.default {
243
316
  this._adapter.notifyFileSelect([newFile]);
244
317
  const newFileItem = this.buildFileItem(newFile, uploadTrigger);
245
318
  const newFileList = [...fileList];
319
+ // replace an item, release its previous objectURL
320
+ const oldItem = newFileList[replaceIdx];
321
+ if (oldItem && oldItem.uid) {
322
+ this._releaseFileUrl(oldItem.uid);
323
+ }
246
324
  newFileList.splice(replaceIdx, 1, newFileItem);
247
325
  this._adapter.notifyChange({
248
326
  currentFile: newFileItem,
@@ -272,7 +350,7 @@ class UploadFoundation extends _foundation.default {
272
350
  uid: fileInstance.uid,
273
351
  percent: 0,
274
352
  fileInstance,
275
- url: this._createURL(fileInstance)
353
+ url: this._createURL(fileInstance, fileInstance.uid)
276
354
  };
277
355
  if (_sizeInvalid) {
278
356
  _file._sizeInvalid = true;
@@ -497,6 +575,7 @@ class UploadFoundation extends _foundation.default {
497
575
  } = buResult;
498
576
  let newFileList = this.getState('fileList').slice();
499
577
  if (autoRemove) {
578
+ this._releaseFileUrl(file.uid);
500
579
  newFileList = newFileList.filter(item => item.uid !== file.uid);
501
580
  } else {
502
581
  const index = this._getFileIndex(file, newFileList);
@@ -510,7 +589,9 @@ class UploadFoundation extends _foundation.default {
510
589
  newFileList[index].fileInstance = fileInstance;
511
590
  newFileList[index].size = (0, _utils.getFileSize)(fileInstance.size);
512
591
  newFileList[index].name = fileInstance.name;
513
- newFileList[index].url = this._createURL(fileInstance);
592
+ // replace preview url, release old one first
593
+ this._releaseFileUrl(file.uid);
594
+ newFileList[index].url = this._createURL(fileInstance, file.uid);
514
595
  }
515
596
  newFileList[index].shouldUpload = shouldUpload;
516
597
  }
@@ -723,8 +804,15 @@ class UploadFoundation extends _foundation.default {
723
804
  status ? newFileList[index].status = status : null;
724
805
  validateMessage ? newFileList[index].validateMessage = validateMessage : null;
725
806
  name ? newFileList[index].name = name : null;
726
- url ? newFileList[index].url = url : null;
727
- autoRemove ? newFileList.splice(index, 1) : null;
807
+ if (url) {
808
+ // if user replaces url, release local objectURL
809
+ this._releaseFileUrl(newFileList[index].uid);
810
+ newFileList[index].url = url;
811
+ }
812
+ if (autoRemove) {
813
+ this._releaseFileUrl(newFileList[index].uid);
814
+ newFileList.splice(index, 1);
815
+ }
728
816
  }
729
817
  this._adapter.notifySuccess(body, fileInstance, newFileList);
730
818
  this._adapter.notifyChange({
@@ -757,6 +845,7 @@ class UploadFoundation extends _foundation.default {
757
845
  return;
758
846
  }
759
847
  newFileList.splice(index, 1);
848
+ this._releaseFileUrl(file.uid);
760
849
  this._adapter.notifyRemove(file.fileInstance, newFileList, file);
761
850
  this._adapter.updateFileList(newFileList);
762
851
  this._adapter.notifyChange({
@@ -811,6 +900,7 @@ class UploadFoundation extends _foundation.default {
811
900
  if (res === false) {
812
901
  return;
813
902
  }
903
+ this._releaseAllFileUrls();
814
904
  this._adapter.updateFileList([]);
815
905
  this._adapter.notifyClear();
816
906
  this._adapter.notifyChange({
@@ -820,26 +910,50 @@ class UploadFoundation extends _foundation.default {
820
910
  // if user pass reject promise, no need to do anything
821
911
  });
822
912
  }
823
- _createURL(fileInstance) {
913
+ _createURL(fileInstance, uid) {
824
914
  // https://stackoverflow.com/questions/31742072/filereader-vs-window-url-createobjecturl
825
915
  const url = URL.createObjectURL(fileInstance);
826
- const {
827
- localUrls
828
- } = this.getStates();
829
- const newUrls = localUrls.slice();
830
- newUrls.push(url);
831
- this._adapter.updateLocalUrls(newUrls);
916
+ const next = Object.assign({}, this._localUrls || {});
917
+ // if there is an old url for this uid, release it first
918
+ if (uid && next[uid] && next[uid] !== url) {
919
+ this._releaseBlob(next[uid]);
920
+ }
921
+ if (uid) {
922
+ next[uid] = url;
923
+ }
924
+ this._localUrls = next;
925
+ // keep adapter/state in sync as a snapshot (legacy array type)
926
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
832
927
  return url;
833
928
  }
834
929
  // 释放预览文件所占用的内存
835
930
  // Release memory used by preview files
836
931
  releaseMemory() {
932
+ // Prefer internal map (canonical). Keep old state-based fallback for safety.
933
+ if (this._localUrls && Object.keys(this._localUrls).length) {
934
+ this._releaseAllFileUrls();
935
+ return;
936
+ }
837
937
  const {
838
938
  localUrls
839
939
  } = this.getStates();
840
- localUrls.forEach(url => {
841
- this._releaseBlob(url);
842
- });
940
+ if (!localUrls) {
941
+ return;
942
+ }
943
+ if (Array.isArray(localUrls)) {
944
+ localUrls.forEach(url => {
945
+ this._releaseBlob(url);
946
+ });
947
+ return;
948
+ }
949
+ if (typeof localUrls === 'object') {
950
+ Object.keys(localUrls).forEach(uid => {
951
+ const url = localUrls[uid];
952
+ if (url) {
953
+ this._releaseBlob(url);
954
+ }
955
+ });
956
+ }
843
957
  }
844
958
  _releaseBlob(url) {
845
959
  try {
@@ -36,6 +36,14 @@ export default class AIChatInputFoundation extends BaseFoundation<AIChatInputAda
36
36
  changeTemplateVisible: (value: boolean) => void;
37
37
  handlePaste: (files: File[]) => void;
38
38
  handleSuggestionSelect: (suggestion: Suggestion) => void;
39
+ /**
40
+ * Delete uploaded file item.
41
+ * Notes:
42
+ * - AIChatInput uses custom upload list UI, so we need to align remove behavior with Upload:
43
+ * 1) respect uploadProps.beforeRemove (support Promise)
44
+ * 2) call uploadProps.onRemove with (currentFile, nextFileList, currentFileItem)
45
+ * 3) still trigger onUploadChange/uploadProps.onChange for fileList update
46
+ */
39
47
  handleUploadFileDelete: (attachment: Attachment) => void;
40
48
  handleReferenceDelete: (reference: Reference) => void;
41
49
  handleReferenceClick: (reference: Reference) => void;
@@ -35,14 +35,50 @@ export default class AIChatInputFoundation extends BaseFoundation {
35
35
  this._adapter.setContent(suggestion);
36
36
  this._adapter.focusEditor();
37
37
  };
38
+ /**
39
+ * Delete uploaded file item.
40
+ * Notes:
41
+ * - AIChatInput uses custom upload list UI, so we need to align remove behavior with Upload:
42
+ * 1) respect uploadProps.beforeRemove (support Promise)
43
+ * 2) call uploadProps.onRemove with (currentFile, nextFileList, currentFileItem)
44
+ * 3) still trigger onUploadChange/uploadProps.onChange for fileList update
45
+ */
38
46
  this.handleUploadFileDelete = attachment => {
47
+ var _a;
39
48
  const {
40
- attachments
49
+ uploadProps
50
+ } = this.getProps();
51
+ const {
52
+ attachments: attachmentsFromState
41
53
  } = this.getStates();
42
- const newAttachments = attachments.filter(item => item.uid !== attachment.uid);
43
- this.onUploadChange({
44
- currentFile: attachment,
45
- fileList: newAttachments
54
+ const attachments = Array.isArray(attachmentsFromState) ? attachmentsFromState : [];
55
+ // Keep consistent with Upload: disabled means no-op
56
+ if (uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.disabled) {
57
+ return;
58
+ }
59
+ const index = attachments.findIndex(item => item.uid === attachment.uid);
60
+ if (index < 0) {
61
+ return;
62
+ }
63
+ const beforeRemove = uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.beforeRemove;
64
+ Promise.resolve((_a = beforeRemove === null || beforeRemove === void 0 ? void 0 : beforeRemove(attachment, attachments)) !== null && _a !== void 0 ? _a : true).then(res => {
65
+ var _a;
66
+ // prevent remove while user return false
67
+ if (res === false) {
68
+ return;
69
+ }
70
+ const newAttachments = attachments.slice();
71
+ newAttachments.splice(index, 1);
72
+ // Align Upload onRemove signature: (File, nextFileList, currentFileItem)
73
+ // currentFile: use original File instance when available
74
+ (_a = uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.onRemove) === null || _a === void 0 ? void 0 : _a.call(uploadProps, attachment.fileInstance, newAttachments, attachment);
75
+ // keep existing behavior: update state + notify upload change
76
+ this.onUploadChange({
77
+ currentFile: attachment,
78
+ fileList: newAttachments
79
+ });
80
+ }).catch(() => {
81
+ // if user pass reject promise, no need to do anything
46
82
  });
47
83
  };
48
84
  this.handleReferenceDelete = reference => {
@@ -81,9 +81,29 @@ export interface UploadAdapter<P = Record<string, any>, S = Record<string, any>>
81
81
  }
82
82
  declare class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<UploadAdapter<P, S>, P, S> {
83
83
  destroyState: boolean;
84
+ /**
85
+ * Canonical storage for objectURL created by this Upload instance.
86
+ * Do NOT rely on React state localUrls because setState is async and may lose updates
87
+ * when _createURL is called multiple times in a sync loop.
88
+ */
89
+ _localUrls: Record<string, string>;
84
90
  constructor(adapter: UploadAdapter<P, S>);
85
91
  init(): void;
92
+ /**
93
+ * Sync internal objectURL map from current fileList.
94
+ * Only track blob: url with an in-memory File instance.
95
+ */
96
+ _syncLocalUrlsFromFileList(): void;
86
97
  destroy(): void;
98
+ /**
99
+ * Release objectURL created for a specific file uid
100
+ */
101
+ _releaseFileUrl(uid: string): void;
102
+ /**
103
+ * Release all objectURL created by this Upload instance.
104
+ * Only call this when files are truly removed (e.g. clear).
105
+ */
106
+ _releaseAllFileUrls(): void;
87
107
  getError({ action, xhr, message, fileName }: {
88
108
  action: string;
89
109
  xhr: XMLHttpRequest;
@@ -156,7 +176,7 @@ declare class UploadFoundation<P = Record<string, any>, S = Record<string, any>>
156
176
  fileInstance: CustomFile;
157
177
  }): void;
158
178
  handleClear(): void;
159
- _createURL(fileInstance: CustomFile): string;
179
+ _createURL(fileInstance: CustomFile, uid: string): string;
160
180
  releaseMemory(): void;
161
181
  _releaseBlob(url: string): void;
162
182
  isImage(file: CustomFile): boolean;
@@ -44,10 +44,19 @@ class UploadFoundation extends BaseFoundation {
44
44
  constructor(adapter) {
45
45
  super(Object.assign({}, adapter));
46
46
  this.destroyState = false;
47
+ /**
48
+ * Canonical storage for objectURL created by this Upload instance.
49
+ * Do NOT rely on React state localUrls because setState is async and may lose updates
50
+ * when _createURL is called multiple times in a sync loop.
51
+ */
52
+ this._localUrls = {};
47
53
  }
48
54
  init() {
49
55
  // make sure state reset, otherwise may cause upload abort in React StrictMode, like https://github.com/DouyinFE/semi-design/pull/843
50
56
  this.destroyState = false;
57
+ // In controlled mode, parent may keep and pass back fileList with blob url.
58
+ // Sync them into internal map so later remove/clear can revoke correctly.
59
+ this._syncLocalUrlsFromFileList();
51
60
  const {
52
61
  disabled,
53
62
  addOnPasting
@@ -56,17 +65,81 @@ class UploadFoundation extends BaseFoundation {
56
65
  this.bindPastingHandler();
57
66
  }
58
67
  }
68
+ /**
69
+ * Sync internal objectURL map from current fileList.
70
+ * Only track blob: url with an in-memory File instance.
71
+ */
72
+ _syncLocalUrlsFromFileList() {
73
+ const {
74
+ fileList
75
+ } = this.getStates();
76
+ const next = {};
77
+ if (!Array.isArray(fileList)) {
78
+ this._localUrls = {};
79
+ this._adapter.updateLocalUrls([]);
80
+ return;
81
+ }
82
+ fileList.forEach(item => {
83
+ const uid = item && item.uid;
84
+ const url = item && item.url;
85
+ const fileInstance = item && item.fileInstance;
86
+ const hasFileCtor = typeof File !== 'undefined';
87
+ const isFile = hasFileCtor && fileInstance instanceof File;
88
+ if (uid && typeof url === 'string' && url.startsWith('blob:') && isFile) {
89
+ next[uid] = url;
90
+ }
91
+ });
92
+ this._localUrls = next;
93
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
94
+ }
59
95
  destroy() {
60
96
  const {
61
97
  disabled,
62
98
  addOnPasting
63
99
  } = this.getProps();
64
- this.releaseMemory();
100
+ // Do NOT revoke objectURL on unmount.
101
+ // In controlled mode, parent may keep and pass back fileList with blob url;
102
+ // revoking here will cause preview/blob ERR_FILE_NOT_FOUND after remount.
65
103
  if (!disabled) {
66
104
  this.unbindPastingHandler();
67
105
  }
68
106
  this.destroyState = true;
69
107
  }
108
+ /**
109
+ * Release objectURL created for a specific file uid
110
+ */
111
+ _releaseFileUrl(uid) {
112
+ if (!uid) {
113
+ return;
114
+ }
115
+ const url = this._localUrls && this._localUrls[uid];
116
+ if (!url) {
117
+ return;
118
+ }
119
+ this._releaseBlob(url);
120
+ const next = Object.assign({}, this._localUrls || {});
121
+ delete next[uid];
122
+ this._localUrls = next;
123
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
124
+ }
125
+ /**
126
+ * Release all objectURL created by this Upload instance.
127
+ * Only call this when files are truly removed (e.g. clear).
128
+ */
129
+ _releaseAllFileUrls() {
130
+ const localUrls = this._localUrls;
131
+ if (!localUrls || typeof localUrls !== 'object') {
132
+ return;
133
+ }
134
+ Object.keys(localUrls).forEach(uid => {
135
+ const url = localUrls[uid];
136
+ if (url) {
137
+ this._releaseBlob(url);
138
+ }
139
+ });
140
+ this._localUrls = {};
141
+ this._adapter.updateLocalUrls([]);
142
+ }
70
143
  getError(_ref) {
71
144
  let {
72
145
  action,
@@ -236,6 +309,11 @@ class UploadFoundation extends BaseFoundation {
236
309
  this._adapter.notifyFileSelect([newFile]);
237
310
  const newFileItem = this.buildFileItem(newFile, uploadTrigger);
238
311
  const newFileList = [...fileList];
312
+ // replace an item, release its previous objectURL
313
+ const oldItem = newFileList[replaceIdx];
314
+ if (oldItem && oldItem.uid) {
315
+ this._releaseFileUrl(oldItem.uid);
316
+ }
239
317
  newFileList.splice(replaceIdx, 1, newFileItem);
240
318
  this._adapter.notifyChange({
241
319
  currentFile: newFileItem,
@@ -265,7 +343,7 @@ class UploadFoundation extends BaseFoundation {
265
343
  uid: fileInstance.uid,
266
344
  percent: 0,
267
345
  fileInstance,
268
- url: this._createURL(fileInstance)
346
+ url: this._createURL(fileInstance, fileInstance.uid)
269
347
  };
270
348
  if (_sizeInvalid) {
271
349
  _file._sizeInvalid = true;
@@ -490,6 +568,7 @@ class UploadFoundation extends BaseFoundation {
490
568
  } = buResult;
491
569
  let newFileList = this.getState('fileList').slice();
492
570
  if (autoRemove) {
571
+ this._releaseFileUrl(file.uid);
493
572
  newFileList = newFileList.filter(item => item.uid !== file.uid);
494
573
  } else {
495
574
  const index = this._getFileIndex(file, newFileList);
@@ -503,7 +582,9 @@ class UploadFoundation extends BaseFoundation {
503
582
  newFileList[index].fileInstance = fileInstance;
504
583
  newFileList[index].size = getFileSize(fileInstance.size);
505
584
  newFileList[index].name = fileInstance.name;
506
- newFileList[index].url = this._createURL(fileInstance);
585
+ // replace preview url, release old one first
586
+ this._releaseFileUrl(file.uid);
587
+ newFileList[index].url = this._createURL(fileInstance, file.uid);
507
588
  }
508
589
  newFileList[index].shouldUpload = shouldUpload;
509
590
  }
@@ -716,8 +797,15 @@ class UploadFoundation extends BaseFoundation {
716
797
  status ? newFileList[index].status = status : null;
717
798
  validateMessage ? newFileList[index].validateMessage = validateMessage : null;
718
799
  name ? newFileList[index].name = name : null;
719
- url ? newFileList[index].url = url : null;
720
- autoRemove ? newFileList.splice(index, 1) : null;
800
+ if (url) {
801
+ // if user replaces url, release local objectURL
802
+ this._releaseFileUrl(newFileList[index].uid);
803
+ newFileList[index].url = url;
804
+ }
805
+ if (autoRemove) {
806
+ this._releaseFileUrl(newFileList[index].uid);
807
+ newFileList.splice(index, 1);
808
+ }
721
809
  }
722
810
  this._adapter.notifySuccess(body, fileInstance, newFileList);
723
811
  this._adapter.notifyChange({
@@ -750,6 +838,7 @@ class UploadFoundation extends BaseFoundation {
750
838
  return;
751
839
  }
752
840
  newFileList.splice(index, 1);
841
+ this._releaseFileUrl(file.uid);
753
842
  this._adapter.notifyRemove(file.fileInstance, newFileList, file);
754
843
  this._adapter.updateFileList(newFileList);
755
844
  this._adapter.notifyChange({
@@ -804,6 +893,7 @@ class UploadFoundation extends BaseFoundation {
804
893
  if (res === false) {
805
894
  return;
806
895
  }
896
+ this._releaseAllFileUrls();
807
897
  this._adapter.updateFileList([]);
808
898
  this._adapter.notifyClear();
809
899
  this._adapter.notifyChange({
@@ -813,26 +903,50 @@ class UploadFoundation extends BaseFoundation {
813
903
  // if user pass reject promise, no need to do anything
814
904
  });
815
905
  }
816
- _createURL(fileInstance) {
906
+ _createURL(fileInstance, uid) {
817
907
  // https://stackoverflow.com/questions/31742072/filereader-vs-window-url-createobjecturl
818
908
  const url = URL.createObjectURL(fileInstance);
819
- const {
820
- localUrls
821
- } = this.getStates();
822
- const newUrls = localUrls.slice();
823
- newUrls.push(url);
824
- this._adapter.updateLocalUrls(newUrls);
909
+ const next = Object.assign({}, this._localUrls || {});
910
+ // if there is an old url for this uid, release it first
911
+ if (uid && next[uid] && next[uid] !== url) {
912
+ this._releaseBlob(next[uid]);
913
+ }
914
+ if (uid) {
915
+ next[uid] = url;
916
+ }
917
+ this._localUrls = next;
918
+ // keep adapter/state in sync as a snapshot (legacy array type)
919
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
825
920
  return url;
826
921
  }
827
922
  // 释放预览文件所占用的内存
828
923
  // Release memory used by preview files
829
924
  releaseMemory() {
925
+ // Prefer internal map (canonical). Keep old state-based fallback for safety.
926
+ if (this._localUrls && Object.keys(this._localUrls).length) {
927
+ this._releaseAllFileUrls();
928
+ return;
929
+ }
830
930
  const {
831
931
  localUrls
832
932
  } = this.getStates();
833
- localUrls.forEach(url => {
834
- this._releaseBlob(url);
835
- });
933
+ if (!localUrls) {
934
+ return;
935
+ }
936
+ if (Array.isArray(localUrls)) {
937
+ localUrls.forEach(url => {
938
+ this._releaseBlob(url);
939
+ });
940
+ return;
941
+ }
942
+ if (typeof localUrls === 'object') {
943
+ Object.keys(localUrls).forEach(uid => {
944
+ const url = localUrls[uid];
945
+ if (url) {
946
+ this._releaseBlob(url);
947
+ }
948
+ });
949
+ }
836
950
  }
837
951
  _releaseBlob(url) {
838
952
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@douyinfe/semi-foundation",
3
- "version": "2.91.0",
3
+ "version": "2.92.1",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "clean": "rimraf lib",
@@ -9495,8 +9495,8 @@
9495
9495
  }
9496
9496
  },
9497
9497
  "dependencies": {
9498
- "@douyinfe/semi-animation": "2.91.0",
9499
- "@douyinfe/semi-json-viewer-core": "2.91.0",
9498
+ "@douyinfe/semi-animation": "2.92.1",
9499
+ "@douyinfe/semi-json-viewer-core": "2.92.1",
9500
9500
  "@mdx-js/mdx": "^3.0.1",
9501
9501
  "async-validator": "^3.5.0",
9502
9502
  "classnames": "^2.2.6",
@@ -9517,7 +9517,7 @@
9517
9517
  "*.scss",
9518
9518
  "*.css"
9519
9519
  ],
9520
- "gitHead": "ead51562c1d0499a5fcde1d828d57d3fd3d2b314",
9520
+ "gitHead": "4819138bf2ef9f2681a1ca2a53baf3ec755acd8a",
9521
9521
  "devDependencies": {
9522
9522
  "@babel/plugin-transform-runtime": "^7.15.8",
9523
9523
  "@babel/preset-env": "^7.15.8",
@@ -81,6 +81,8 @@ export interface UploadAdapter<P = Record<string, any>, S = Record<string, any>>
81
81
  notifyBeforeRemove: (file: BaseFileItem, fileList: Array<BaseFileItem>) => boolean | Promise<boolean>;
82
82
  notifyBeforeClear: (fileList: Array<BaseFileItem>) => boolean | Promise<boolean>;
83
83
  notifyChange: ({ currentFile, fileList }: { currentFile: BaseFileItem | null; fileList: Array<BaseFileItem> }) => void;
84
+ // Keep this as Array<string> for backward compatibility (external type stability).
85
+ // Internal logic uses uid->url map to avoid setState async timing issues.
84
86
  updateLocalUrls: (urls: Array<string>) => void;
85
87
  notifyClear: () => void;
86
88
  notifyPreviewClick: (file: any) => void;
@@ -95,6 +97,12 @@ export interface UploadAdapter<P = Record<string, any>, S = Record<string, any>>
95
97
 
96
98
  class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<UploadAdapter<P, S>, P, S> {
97
99
  destroyState: boolean = false;
100
+ /**
101
+ * Canonical storage for objectURL created by this Upload instance.
102
+ * Do NOT rely on React state localUrls because setState is async and may lose updates
103
+ * when _createURL is called multiple times in a sync loop.
104
+ */
105
+ _localUrls: Record<string, string> = {};
98
106
  constructor(adapter: UploadAdapter<P, S>) {
99
107
  super({ ...adapter });
100
108
  }
@@ -102,21 +110,89 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
102
110
  init(): void {
103
111
  // make sure state reset, otherwise may cause upload abort in React StrictMode, like https://github.com/DouyinFE/semi-design/pull/843
104
112
  this.destroyState = false;
113
+ // In controlled mode, parent may keep and pass back fileList with blob url.
114
+ // Sync them into internal map so later remove/clear can revoke correctly.
115
+ this._syncLocalUrlsFromFileList();
105
116
  const { disabled, addOnPasting } = this.getProps();
106
117
  if (addOnPasting && !disabled) {
107
118
  this.bindPastingHandler();
108
119
  }
109
120
  }
110
121
 
122
+ /**
123
+ * Sync internal objectURL map from current fileList.
124
+ * Only track blob: url with an in-memory File instance.
125
+ */
126
+ _syncLocalUrlsFromFileList(): void {
127
+ const { fileList } = this.getStates() as any;
128
+ const next: Record<string, string> = {};
129
+ if (!Array.isArray(fileList)) {
130
+ this._localUrls = {};
131
+ this._adapter.updateLocalUrls([]);
132
+ return;
133
+ }
134
+ fileList.forEach((item: any) => {
135
+ const uid = item && item.uid;
136
+ const url = item && item.url;
137
+ const fileInstance = item && item.fileInstance;
138
+ const hasFileCtor = typeof File !== 'undefined';
139
+ const isFile = hasFileCtor && fileInstance instanceof File;
140
+ if (uid && typeof url === 'string' && url.startsWith('blob:') && isFile) {
141
+ next[uid] = url;
142
+ }
143
+ });
144
+ this._localUrls = next;
145
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
146
+ }
147
+
111
148
  destroy() {
112
149
  const { disabled, addOnPasting } = this.getProps();
113
- this.releaseMemory();
150
+ // Do NOT revoke objectURL on unmount.
151
+ // In controlled mode, parent may keep and pass back fileList with blob url;
152
+ // revoking here will cause preview/blob ERR_FILE_NOT_FOUND after remount.
114
153
  if (!disabled) {
115
154
  this.unbindPastingHandler();
116
155
  }
117
156
  this.destroyState = true;
118
157
  }
119
158
 
159
+ /**
160
+ * Release objectURL created for a specific file uid
161
+ */
162
+ _releaseFileUrl(uid: string): void {
163
+ if (!uid) {
164
+ return;
165
+ }
166
+ const url = this._localUrls && this._localUrls[uid];
167
+ if (!url) {
168
+ return;
169
+ }
170
+ this._releaseBlob(url);
171
+ const next = { ...(this._localUrls || {}) };
172
+ delete next[uid];
173
+ this._localUrls = next;
174
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
175
+ }
176
+
177
+ /**
178
+ * Release all objectURL created by this Upload instance.
179
+ * Only call this when files are truly removed (e.g. clear).
180
+ */
181
+ _releaseAllFileUrls(): void {
182
+ const localUrls = this._localUrls;
183
+ if (!localUrls || typeof localUrls !== 'object') {
184
+ return;
185
+ }
186
+ Object.keys(localUrls).forEach(uid => {
187
+ const url = localUrls[uid];
188
+ if (url) {
189
+ this._releaseBlob(url);
190
+ }
191
+ });
192
+ this._localUrls = {};
193
+ this._adapter.updateLocalUrls([]);
194
+ }
195
+
120
196
  getError({ action, xhr, message, fileName }: { action: string;xhr: XMLHttpRequest;message?: string;fileName: string }): XhrError {
121
197
  const status = xhr ? xhr.status : 0;
122
198
  const msg = message || `cannot post ${fileName} to ${action}, xhr status: ${status}'`;
@@ -272,6 +348,12 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
272
348
  this._adapter.notifyFileSelect([newFile]);
273
349
  const newFileItem = this.buildFileItem(newFile, uploadTrigger);
274
350
  const newFileList = [...fileList];
351
+
352
+ // replace an item, release its previous objectURL
353
+ const oldItem = newFileList[replaceIdx];
354
+ if (oldItem && oldItem.uid) {
355
+ this._releaseFileUrl(oldItem.uid);
356
+ }
275
357
  newFileList.splice(replaceIdx, 1, newFileItem);
276
358
  this._adapter.notifyChange({ currentFile: newFileItem, fileList: newFileList });
277
359
  this._adapter.updateFileList(newFileList, () => {
@@ -297,7 +379,7 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
297
379
  uid: fileInstance.uid,
298
380
  percent: 0,
299
381
  fileInstance,
300
- url: this._createURL(fileInstance),
382
+ url: this._createURL(fileInstance, fileInstance.uid),
301
383
  };
302
384
 
303
385
  if (_sizeInvalid) {
@@ -497,6 +579,7 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
497
579
  const { shouldUpload, status, autoRemove, validateMessage, fileInstance } = buResult;
498
580
  let newFileList: Array<BaseFileItem> = this.getState('fileList').slice();
499
581
  if (autoRemove) {
582
+ this._releaseFileUrl(file.uid);
500
583
  newFileList = newFileList.filter(item => item.uid !== file.uid);
501
584
  } else {
502
585
  const index = this._getFileIndex(file, newFileList);
@@ -510,7 +593,9 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
510
593
  newFileList[index].fileInstance = fileInstance;
511
594
  newFileList[index].size = getFileSize(fileInstance.size);
512
595
  newFileList[index].name = fileInstance.name;
513
- newFileList[index].url = this._createURL(fileInstance);
596
+ // replace preview url, release old one first
597
+ this._releaseFileUrl(file.uid);
598
+ newFileList[index].url = this._createURL(fileInstance, file.uid);
514
599
  }
515
600
  newFileList[index].shouldUpload = shouldUpload;
516
601
  }
@@ -674,8 +759,15 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
674
759
  status ? (newFileList[index].status = status) : null;
675
760
  validateMessage ? (newFileList[index].validateMessage = validateMessage) : null;
676
761
  name ? (newFileList[index].name = name) : null;
677
- url ? (newFileList[index].url = url) : null;
678
- autoRemove ? newFileList.splice(index, 1) : null;
762
+ if (url) {
763
+ // if user replaces url, release local objectURL
764
+ this._releaseFileUrl(newFileList[index].uid);
765
+ newFileList[index].url = url;
766
+ }
767
+ if (autoRemove) {
768
+ this._releaseFileUrl(newFileList[index].uid);
769
+ newFileList.splice(index, 1);
770
+ }
679
771
  }
680
772
  this._adapter.notifySuccess(body, fileInstance, newFileList);
681
773
  this._adapter.notifyChange({ fileList: newFileList, currentFile: newFileList[index] });
@@ -706,6 +798,8 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
706
798
  }
707
799
  newFileList.splice(index, 1);
708
800
 
801
+ this._releaseFileUrl(file.uid);
802
+
709
803
  this._adapter.notifyRemove(file.fileInstance, newFileList, file);
710
804
  this._adapter.updateFileList(newFileList);
711
805
  this._adapter.notifyChange({ fileList: newFileList, currentFile: file });
@@ -742,6 +836,7 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
742
836
  if (res === false) {
743
837
  return;
744
838
  }
839
+ this._releaseAllFileUrls();
745
840
  this._adapter.updateFileList([]);
746
841
  this._adapter.notifyClear();
747
842
  this._adapter.notifyChange({ fileList: [] } as any);
@@ -750,23 +845,50 @@ class UploadFoundation<P = Record<string, any>, S = Record<string, any>> extends
750
845
  });
751
846
  }
752
847
 
753
- _createURL(fileInstance: CustomFile): string {
848
+ _createURL(fileInstance: CustomFile, uid: string): string {
754
849
  // https://stackoverflow.com/questions/31742072/filereader-vs-window-url-createobjecturl
755
850
  const url = URL.createObjectURL(fileInstance);
756
- const { localUrls } = this.getStates();
757
- const newUrls = localUrls.slice();
758
- newUrls.push(url);
759
- this._adapter.updateLocalUrls(newUrls);
851
+ const next = { ...(this._localUrls || {}) };
852
+ // if there is an old url for this uid, release it first
853
+ if (uid && next[uid] && next[uid] !== url) {
854
+ this._releaseBlob(next[uid]);
855
+ }
856
+ if (uid) {
857
+ next[uid] = url;
858
+ }
859
+ this._localUrls = next;
860
+ // keep adapter/state in sync as a snapshot (legacy array type)
861
+ this._adapter.updateLocalUrls(Object.values(this._localUrls));
760
862
  return url;
761
863
  }
762
864
 
763
865
  // 释放预览文件所占用的内存
764
866
  // Release memory used by preview files
765
867
  releaseMemory(): void {
766
- const { localUrls }: { localUrls: Array<string> } = this.getStates();
767
- localUrls.forEach(url => {
768
- this._releaseBlob(url);
769
- });
868
+ // Prefer internal map (canonical). Keep old state-based fallback for safety.
869
+ if (this._localUrls && Object.keys(this._localUrls).length) {
870
+ this._releaseAllFileUrls();
871
+ return;
872
+ }
873
+
874
+ const { localUrls } = this.getStates() as any;
875
+ if (!localUrls) {
876
+ return;
877
+ }
878
+ if (Array.isArray(localUrls)) {
879
+ localUrls.forEach(url => {
880
+ this._releaseBlob(url);
881
+ });
882
+ return;
883
+ }
884
+ if (typeof localUrls === 'object') {
885
+ Object.keys(localUrls).forEach(uid => {
886
+ const url = localUrls[uid];
887
+ if (url) {
888
+ this._releaseBlob(url);
889
+ }
890
+ });
891
+ }
770
892
  }
771
893
 
772
894
  _releaseBlob(url: string): void {