mirror of
https://github.com/filamentphp/filament.git
synced 2026-10-13 01:16:57 +08:00
fix: clean up JavaScript resources on destruction
This commit is contained in:
@@ -157,6 +157,12 @@ Interfaces in `Contracts/` directories.
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### JavaScript lifecycle cleanup
|
||||
|
||||
- Alpine components and directives must release resources they own in `destroy()` or `cleanup()`, including listeners on surviving ancestors or global objects, observers, animation loops, and third-party instances. Listeners attached only to removed subtree elements do not need separate cleanup.
|
||||
- Guard asynchronous initialization and callbacks when they could create surviving resources or cause observable stale behavior after destruction. Use the third-party instance's real teardown API, verified against its source when necessary.
|
||||
- Do not add cleanup solely for short-lived timers; cancel or guard them when their callback could still cause stale behavior after destruction.
|
||||
|
||||
### PHPDoc
|
||||
|
||||
Only add when providing type info beyond native PHP types:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
+23
-23
File diff suppressed because one or more lines are too long
+42
-42
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
|
||||
function n({initialHeight:e,shouldAutosize:s,state:i}){return{state:i,resizeObserver:null,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let r=this.$el.style.height;this.$el.style.height="0px";let h=this.$el.scrollHeight;this.$el.style.height=r;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),t=Math.max(h,l)+"px";this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){this.resizeObserver=new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}),this.resizeObserver.observe(this.$el)},destroy(){this.resizeObserver?.disconnect()}}}export{n as default};
|
||||
|
||||
@@ -12,9 +12,13 @@ export default function colorPickerFormComponent({
|
||||
liveDebounce,
|
||||
state,
|
||||
}) {
|
||||
let isDestroyed = false
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
panelObserver: null,
|
||||
|
||||
init() {
|
||||
if (!(this.state === null || this.state === '')) {
|
||||
this.setState(this.state)
|
||||
@@ -27,6 +31,10 @@ export default function colorPickerFormComponent({
|
||||
this.$watch(
|
||||
'state',
|
||||
Alpine.debounce((value) => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!CSS.supports('color', value)) {
|
||||
return
|
||||
}
|
||||
@@ -48,6 +56,10 @@ export default function colorPickerFormComponent({
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state !== event.detail.value) {
|
||||
return
|
||||
}
|
||||
@@ -59,9 +71,11 @@ export default function colorPickerFormComponent({
|
||||
})
|
||||
|
||||
if (isLive || isLiveDebounced || isLiveOnBlur) {
|
||||
new MutationObserver(() =>
|
||||
this.panelObserver = new MutationObserver(() =>
|
||||
this.isOpen() ? null : this.commitState(),
|
||||
).observe(this.$refs.panel, {
|
||||
)
|
||||
|
||||
this.panelObserver.observe(this.$refs.panel, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
})
|
||||
@@ -88,6 +102,10 @@ export default function colorPickerFormComponent({
|
||||
},
|
||||
|
||||
commitState() {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(this.$wire.__instance.canonical) ===
|
||||
JSON.stringify(this.$wire.__instance.ephemeral)
|
||||
@@ -97,5 +115,10 @@ export default function colorPickerFormComponent({
|
||||
|
||||
this.$wire.$commit()
|
||||
},
|
||||
|
||||
destroy() {
|
||||
isDestroyed = true
|
||||
this.panelObserver?.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,13 @@ export default function fileUploadFormComponent({
|
||||
uploadProgressIndicatorPosition,
|
||||
uploadUsing,
|
||||
}) {
|
||||
let isDestroyed = false
|
||||
|
||||
return {
|
||||
fileKeyIndex: {},
|
||||
|
||||
form: null,
|
||||
|
||||
pond: null,
|
||||
|
||||
shouldUpdateState: true,
|
||||
@@ -102,6 +106,8 @@ export default function fileUploadFormComponent({
|
||||
|
||||
isEditorOpenedForAspectRatio: false,
|
||||
|
||||
isProcessingFiles: false,
|
||||
|
||||
editingFile: {},
|
||||
|
||||
currentRatio: '',
|
||||
@@ -115,11 +121,12 @@ export default function fileUploadFormComponent({
|
||||
isInitializing: false,
|
||||
|
||||
async init() {
|
||||
if (this.pond || this.isInitializing) {
|
||||
if (isDestroyed || this.pond || this.isInitializing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isInitializing = true
|
||||
this.form = this.$el.closest('form')
|
||||
|
||||
// https://github.com/filamentphp/filament/issues/15394
|
||||
// https://github.com/filamentphp/filament/issues/16253
|
||||
@@ -171,6 +178,14 @@ export default function fileUploadFormComponent({
|
||||
|
||||
FilePond.setOptions(locales[locale] ?? locales['en'])
|
||||
|
||||
const files = await this.getFiles()
|
||||
|
||||
if (isDestroyed) {
|
||||
this.isInitializing = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.pond = FilePond.create(this.$refs.input, {
|
||||
acceptedFileTypes,
|
||||
allowImageExifOrientation: shouldOrientImageFromExif,
|
||||
@@ -205,7 +220,7 @@ export default function fileUploadFormComponent({
|
||||
return false
|
||||
},
|
||||
credits: false,
|
||||
files: await this.getFiles(),
|
||||
files,
|
||||
imageCropAspectRatio: automaticallyCropImagesAspectRatio,
|
||||
imagePreviewHeight,
|
||||
imageResizeTargetHeight: automaticallyResizeImagesHeight,
|
||||
@@ -402,7 +417,13 @@ export default function fileUploadFormComponent({
|
||||
return
|
||||
}
|
||||
|
||||
this.pond.files = await this.getFiles()
|
||||
const files = await this.getFiles()
|
||||
|
||||
if (isDestroyed || !this.pond) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pond.files = files
|
||||
})
|
||||
|
||||
this.pond.on('reorderfiles', async (files) => {
|
||||
@@ -422,6 +443,10 @@ export default function fileUploadFormComponent({
|
||||
})
|
||||
|
||||
this.pond.on('initfile', async (fileItem) => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDownloadable) {
|
||||
return
|
||||
}
|
||||
@@ -434,6 +459,10 @@ export default function fileUploadFormComponent({
|
||||
})
|
||||
|
||||
this.pond.on('initfile', async (fileItem) => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOpenable) {
|
||||
return
|
||||
}
|
||||
@@ -445,20 +474,22 @@ export default function fileUploadFormComponent({
|
||||
this.insertOpenLink(fileItem)
|
||||
})
|
||||
|
||||
let isProcessingFiles = false
|
||||
|
||||
this.pond.on('addfilestart', async (file) => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.error = null
|
||||
|
||||
if (file.status !== FilePond.FileStatus.PROCESSING_QUEUED) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isProcessingFiles) {
|
||||
if (this.isProcessingFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingFiles = true
|
||||
this.isProcessingFiles = true
|
||||
|
||||
this.dispatchFormEvent('form-processing-started', {
|
||||
message: uploadingMessage,
|
||||
@@ -466,6 +497,10 @@ export default function fileUploadFormComponent({
|
||||
})
|
||||
|
||||
const handleFileProcessing = async () => {
|
||||
if (isDestroyed || !this.pond) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
this.pond
|
||||
.getFiles()
|
||||
@@ -480,11 +515,11 @@ export default function fileUploadFormComponent({
|
||||
return
|
||||
}
|
||||
|
||||
if (!isProcessingFiles) {
|
||||
if (!this.isProcessingFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingFiles = false
|
||||
this.isProcessingFiles = false
|
||||
|
||||
this.dispatchFormEvent('form-processing-finished')
|
||||
}
|
||||
@@ -525,19 +560,27 @@ export default function fileUploadFormComponent({
|
||||
},
|
||||
|
||||
destroy() {
|
||||
isDestroyed = true
|
||||
this.isInitializing = false
|
||||
|
||||
if (this.isProcessingFiles) {
|
||||
this.isProcessingFiles = false
|
||||
this.dispatchFormEvent('form-processing-finished')
|
||||
}
|
||||
|
||||
this.visibilityObserver?.disconnect()
|
||||
this.intersectionObserver?.disconnect()
|
||||
|
||||
this.destroyEditor()
|
||||
|
||||
if (this.pond) {
|
||||
FilePond.destroy(this.$refs.input)
|
||||
this.pond.destroy()
|
||||
this.pond = null
|
||||
}
|
||||
},
|
||||
|
||||
dispatchFormEvent(name, detail = {}) {
|
||||
this.$el.closest('form')?.dispatchEvent(
|
||||
this.form?.dispatchEvent(
|
||||
new CustomEvent(name, {
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
@@ -832,6 +875,10 @@ export default function fileUploadFormComponent({
|
||||
}
|
||||
|
||||
this.fixImageDimensions(file, (editingFile) => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.editingFile = editingFile
|
||||
|
||||
this.initEditor()
|
||||
@@ -839,12 +886,19 @@ export default function fileUploadFormComponent({
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (event) => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isEditorOpen = true
|
||||
|
||||
setTimeout(
|
||||
() => this.editor.replace(event.target.result),
|
||||
200,
|
||||
)
|
||||
setTimeout(() => {
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.editor.replace(event.target.result)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
@@ -903,6 +957,10 @@ export default function fileUploadFormComponent({
|
||||
|
||||
croppedCanvas.toBlob(
|
||||
(croppedImage) => {
|
||||
if (isDestroyed || !this.pond) {
|
||||
return
|
||||
}
|
||||
|
||||
const editingFileItem = this.pond
|
||||
.getFiles()
|
||||
.find(
|
||||
@@ -917,6 +975,10 @@ export default function fileUploadFormComponent({
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (isDestroyed || !this.pond) {
|
||||
return
|
||||
}
|
||||
|
||||
this.shouldUpdateState = false
|
||||
|
||||
let editingFileName = this.editingFile.name.slice(
|
||||
|
||||
+38
-6
@@ -5,6 +5,9 @@
|
||||
* - Fixed upstream PR #33 `mediaPreviewHeight` implementation.
|
||||
* - Prevented audio controls from initiating file reordering.
|
||||
* - Corrected audio timeline positioning when the preview is transformed or resized.
|
||||
* - Added stable references for global audio drag listeners and an `AudioPlayer.destroy()` method to remove them.
|
||||
* - Retained the `AudioPlayer` on the FilePond view and destroyed it through the view's `destroy` callback.
|
||||
* - Tracked generated media object URLs and revoked them before replacement and when the view is destroyed.
|
||||
*/
|
||||
|
||||
const isPreviewableVideo = (file) => /^video/.test(file.type)
|
||||
@@ -18,6 +21,7 @@ class AudioPlayer {
|
||||
this.onPlayhead = false
|
||||
this.duration = 0
|
||||
this.movePlayheadHandler = this.movePlayhead.bind(this)
|
||||
this.mouseUpHandler = this.mouseUp.bind(this)
|
||||
|
||||
this.registerListeners()
|
||||
}
|
||||
@@ -52,7 +56,7 @@ class AudioPlayer {
|
||||
this.mouseDown.bind(this),
|
||||
false,
|
||||
)
|
||||
window.addEventListener('mouseup', this.mouseUp.bind(this), false)
|
||||
window.addEventListener('mouseup', this.mouseUpHandler, false)
|
||||
}
|
||||
|
||||
play() {
|
||||
@@ -124,6 +128,11 @@ class AudioPlayer {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('mousemove', this.movePlayheadHandler, true)
|
||||
window.removeEventListener('mouseup', this.mouseUpHandler, false)
|
||||
}
|
||||
}
|
||||
|
||||
const createMediaView = (_) =>
|
||||
@@ -171,15 +180,28 @@ const createMediaView = (_) =>
|
||||
}
|
||||
|
||||
const url = window.URL || window.webkitURL
|
||||
const blob = new Blob([item.file], { type: item.file.type })
|
||||
|
||||
if (root.ref.objectUrl) {
|
||||
url.revokeObjectURL(root.ref.objectUrl)
|
||||
root.ref.objectUrl = null
|
||||
}
|
||||
|
||||
root.ref.media.type = item.file.type
|
||||
root.ref.media.src =
|
||||
(item.file.mock && item.file.url) ||
|
||||
url.createObjectURL(blob)
|
||||
|
||||
if (item.file.mock && item.file.url) {
|
||||
root.ref.media.src = item.file.url
|
||||
} else {
|
||||
const blob = new Blob([item.file], { type: item.file.type })
|
||||
|
||||
root.ref.objectUrl = url.createObjectURL(blob)
|
||||
root.ref.media.src = root.ref.objectUrl
|
||||
}
|
||||
|
||||
if (isPreviewableAudio(item.file)) {
|
||||
new AudioPlayer(root.ref.media, root.ref.audio)
|
||||
root.ref.audioPlayer = new AudioPlayer(
|
||||
root.ref.media,
|
||||
root.ref.audio,
|
||||
)
|
||||
}
|
||||
|
||||
root.ref.media.addEventListener(
|
||||
@@ -211,6 +233,16 @@ const createMediaView = (_) =>
|
||||
)
|
||||
},
|
||||
}),
|
||||
destroy: ({ root }) => {
|
||||
root.ref.audioPlayer?.destroy()
|
||||
|
||||
if (root.ref.objectUrl) {
|
||||
const url = window.URL || window.webkitURL
|
||||
|
||||
url.revokeObjectURL(root.ref.objectUrl)
|
||||
root.ref.objectUrl = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const createMediaWrapperView = (_) => {
|
||||
|
||||
@@ -97,9 +97,13 @@ export default function markdownEditorFormComponent({
|
||||
toolbarButtons,
|
||||
uploadFileAttachmentUsing,
|
||||
}) {
|
||||
let isDestroyed = false
|
||||
|
||||
return {
|
||||
editor: null,
|
||||
|
||||
form: null,
|
||||
|
||||
state,
|
||||
|
||||
wasEditorVisible: false,
|
||||
@@ -116,11 +120,17 @@ export default function markdownEditorFormComponent({
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.$root._editor) {
|
||||
this.$root._editor.toTextArea()
|
||||
this.$root._editor = null
|
||||
}
|
||||
|
||||
this.form = this.$refs.editor.form
|
||||
|
||||
this.$root._editor = this.editor = new EasyMDE({
|
||||
autoDownloadFontAwesome: false,
|
||||
autoRefresh: true,
|
||||
@@ -296,13 +306,25 @@ export default function markdownEditorFormComponent({
|
||||
},
|
||||
|
||||
destroy() {
|
||||
isDestroyed = true
|
||||
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = null
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
this.intersectionObserver = null
|
||||
|
||||
this.editor.cleanup()
|
||||
if (this.editor) {
|
||||
this.editor.codemirror.setOption('autoRefresh', false)
|
||||
this.form?.removeEventListener(
|
||||
'submit',
|
||||
this.editor.codemirror.save,
|
||||
)
|
||||
this.editor.cleanup()
|
||||
this.editor.toTextArea()
|
||||
}
|
||||
|
||||
this.$root._editor = null
|
||||
this.editor = null
|
||||
},
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* - Wrapped the indent/outdent operations in `toggleCodeBlock()` in `cm.operation()` so they group into a single CodeMirror undo step. See https://github.com/filamentphp/filament/pull/19890.
|
||||
* - Changed `minHeight` and `maxHeight` to apply independently when both options are set. See https://github.com/Ionaru/easy-markdown-editor/issues/413.
|
||||
* - Made the `CodeMirror` scroller keyboard-focusable when `maxHeight` is set so overflowing content can be scrolled with a keyboard. See https://github.com/filamentphp/filament/pull/20258.
|
||||
* - Prevented `CodeMirror` from wrapping the ancestor form's `submit()` method, so detached editors can fully release their form listener during teardown.
|
||||
*/
|
||||
|
||||
// Some variables
|
||||
@@ -2531,6 +2532,7 @@ EasyMDE.prototype.render = function (el) {
|
||||
: true,
|
||||
autoRefresh:
|
||||
options.autoRefresh != undefined ? options.autoRefresh : false,
|
||||
leaveSubmitMethodAlone: true,
|
||||
})
|
||||
|
||||
var scrollerElement = this.codemirror.getScrollerElement()
|
||||
|
||||
@@ -70,6 +70,52 @@ export default function richEditorFormComponent({
|
||||
editorUpdatedAt: Date.now(),
|
||||
|
||||
async init() {
|
||||
const resolvedExtensions = await getExtensions({
|
||||
acceptedFileTypes,
|
||||
acceptedFileTypesValidationMessage,
|
||||
canAttachFiles,
|
||||
customExtensionUrls: extensions,
|
||||
deleteCustomBlockButtonIconHtml,
|
||||
editCustomBlockButtonIconHtml,
|
||||
editCustomBlockUsing: (id, config) =>
|
||||
this.$wire.mountAction(
|
||||
'customBlock',
|
||||
{
|
||||
editorSelection: this.editorSelection,
|
||||
id,
|
||||
config,
|
||||
mode: 'edit',
|
||||
},
|
||||
{ schemaComponent: key },
|
||||
),
|
||||
floatingToolbars,
|
||||
hasResizableImages,
|
||||
insertCustomBlockUsing: (id, dragPosition = null) =>
|
||||
this.$wire.mountAction(
|
||||
'customBlock',
|
||||
{ id, dragPosition, mode: 'insert' },
|
||||
{ schemaComponent: key },
|
||||
),
|
||||
key,
|
||||
linkProtocols,
|
||||
maxFileSize,
|
||||
maxFileSizeValidationMessage,
|
||||
mergeTags,
|
||||
mentions,
|
||||
getMentionSearchResultsUsing,
|
||||
getMentionLabelsUsing,
|
||||
noMergeTagSearchResultsMessage,
|
||||
placeholder,
|
||||
statePath,
|
||||
textColors,
|
||||
uploadingFileMessage,
|
||||
$wire: this.$wire,
|
||||
})
|
||||
|
||||
if (isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
editor = new Editor({
|
||||
editable: !isDisabled,
|
||||
element: this.$refs.editor,
|
||||
@@ -78,47 +124,7 @@ export default function richEditorFormComponent({
|
||||
...(label ? { 'aria-label': label } : {}),
|
||||
},
|
||||
},
|
||||
extensions: await getExtensions({
|
||||
acceptedFileTypes,
|
||||
acceptedFileTypesValidationMessage,
|
||||
canAttachFiles,
|
||||
customExtensionUrls: extensions,
|
||||
deleteCustomBlockButtonIconHtml,
|
||||
editCustomBlockButtonIconHtml,
|
||||
editCustomBlockUsing: (id, config) =>
|
||||
this.$wire.mountAction(
|
||||
'customBlock',
|
||||
{
|
||||
editorSelection: this.editorSelection,
|
||||
id,
|
||||
config,
|
||||
mode: 'edit',
|
||||
},
|
||||
{ schemaComponent: key },
|
||||
),
|
||||
floatingToolbars,
|
||||
hasResizableImages,
|
||||
insertCustomBlockUsing: (id, dragPosition = null) =>
|
||||
this.$wire.mountAction(
|
||||
'customBlock',
|
||||
{ id, dragPosition, mode: 'insert' },
|
||||
{ schemaComponent: key },
|
||||
),
|
||||
key,
|
||||
linkProtocols,
|
||||
maxFileSize,
|
||||
maxFileSizeValidationMessage,
|
||||
mergeTags,
|
||||
mentions,
|
||||
getMentionSearchResultsUsing,
|
||||
getMentionLabelsUsing,
|
||||
noMergeTagSearchResultsMessage,
|
||||
placeholder,
|
||||
statePath,
|
||||
textColors,
|
||||
uploadingFileMessage,
|
||||
$wire: this.$wire,
|
||||
}),
|
||||
extensions: resolvedExtensions,
|
||||
content: this.state,
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export default function textareaFormComponent({
|
||||
return {
|
||||
state,
|
||||
|
||||
resizeObserver: null,
|
||||
|
||||
wrapperEl: null,
|
||||
|
||||
init() {
|
||||
@@ -54,11 +56,15 @@ export default function textareaFormComponent({
|
||||
},
|
||||
|
||||
setUpResizeObserver() {
|
||||
const observer = new ResizeObserver(() => {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.wrapperEl.style.height = this.$el.style.height
|
||||
})
|
||||
|
||||
observer.observe(this.$el)
|
||||
this.resizeObserver.observe(this.$el)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.resizeObserver?.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -10,6 +10,10 @@ export default (Alpine) => {
|
||||
|
||||
transitionEasing: null,
|
||||
|
||||
closeTimeout: null,
|
||||
|
||||
durationTimeout: null,
|
||||
|
||||
unsubscribeLivewireHook: null,
|
||||
|
||||
init() {
|
||||
@@ -27,7 +31,7 @@ export default (Alpine) => {
|
||||
notification.duration &&
|
||||
notification.duration !== 'persistent'
|
||||
) {
|
||||
setTimeout(() => {
|
||||
this.durationTimeout = setTimeout(() => {
|
||||
if (!this.$el.matches(':hover')) {
|
||||
this.close()
|
||||
|
||||
@@ -160,6 +164,9 @@ export default (Alpine) => {
|
||||
},
|
||||
|
||||
close(isImmediate = false) {
|
||||
clearTimeout(this.closeTimeout)
|
||||
clearTimeout(this.durationTimeout)
|
||||
|
||||
const dispatchClosedEvent = () =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('notificationClosed', {
|
||||
@@ -188,7 +195,10 @@ export default (Alpine) => {
|
||||
|
||||
this.isShown = false
|
||||
|
||||
setTimeout(dispatchClosedEvent, this.transitionDuration)
|
||||
this.closeTimeout = setTimeout(
|
||||
dispatchClosedEvent,
|
||||
this.transitionDuration,
|
||||
)
|
||||
},
|
||||
|
||||
markAsRead() {
|
||||
@@ -212,6 +222,8 @@ export default (Alpine) => {
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.closeTimeout)
|
||||
clearTimeout(this.durationTimeout)
|
||||
this.unsubscribeLivewireHook?.()
|
||||
},
|
||||
}))
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
window.setUpUnsavedDataChangesAlert = ({ body, livewireComponent, $wire }) => {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
const beforeUnloadHandler = (event) => {
|
||||
if (
|
||||
window.jsMd5(JSON.stringify($wire.data).replace(/\\/g, '')) ===
|
||||
$wire.savedDataHash ||
|
||||
@@ -10,7 +10,13 @@ window.setUpUnsavedDataChangesAlert = ({ body, livewireComponent, $wire }) => {
|
||||
|
||||
event.preventDefault()
|
||||
event.returnValue = true
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
|
||||
$wire.__instance.addCleanup(() =>
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler),
|
||||
)
|
||||
}
|
||||
|
||||
window.setUpSpaModeUnsavedDataChangesAlert = ({
|
||||
@@ -33,7 +39,7 @@ window.setUpSpaModeUnsavedDataChangesAlert = ({
|
||||
return confirm(body)
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:navigate', (event) => {
|
||||
const navigateHandler = (event) => {
|
||||
if (typeof resolveLivewireComponentUsing() !== 'undefined') {
|
||||
if (!shouldPreventNavigation()) {
|
||||
return
|
||||
@@ -45,15 +51,23 @@ window.setUpSpaModeUnsavedDataChangesAlert = ({
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
const beforeUnloadHandler = (event) => {
|
||||
if (!shouldPreventNavigation()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.returnValue = true
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:navigate', navigateHandler)
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
|
||||
$wire.__instance.addCleanup(() => {
|
||||
document.removeEventListener('livewire:navigate', navigateHandler)
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,7 +75,7 @@ window.setUpUnsavedActionChangesAlert = ({
|
||||
resolveLivewireComponentUsing,
|
||||
$wire,
|
||||
}) => {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
const beforeUnloadHandler = (event) => {
|
||||
if (typeof resolveLivewireComponentUsing() === 'undefined') {
|
||||
return
|
||||
}
|
||||
@@ -77,5 +91,11 @@ window.setUpUnsavedActionChangesAlert = ({
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
|
||||
$wire.__instance.addCleanup(() =>
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler),
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
|
||||
export default () => ({
|
||||
form: null,
|
||||
|
||||
eventListenersController: null,
|
||||
|
||||
processingCount: 0,
|
||||
|
||||
isProcessing: false,
|
||||
@@ -8,17 +10,33 @@ export default () => ({
|
||||
processingMessage: null,
|
||||
|
||||
init() {
|
||||
const formElement = this.$el.closest('form')
|
||||
this.form = this.$el.closest('form')
|
||||
|
||||
formElement?.addEventListener('form-processing-started', (event) => {
|
||||
this.processingCount++
|
||||
this.isProcessing = true
|
||||
this.processingMessage = event.detail.message
|
||||
})
|
||||
this.eventListenersController = new AbortController()
|
||||
|
||||
formElement?.addEventListener('form-processing-finished', () => {
|
||||
this.processingCount = Math.max(0, this.processingCount - 1)
|
||||
this.isProcessing = this.processingCount > 0
|
||||
})
|
||||
const { signal } = this.eventListenersController
|
||||
|
||||
this.form?.addEventListener(
|
||||
'form-processing-started',
|
||||
(event) => {
|
||||
this.processingCount++
|
||||
this.isProcessing = true
|
||||
this.processingMessage = event.detail.message
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
|
||||
this.form?.addEventListener(
|
||||
'form-processing-finished',
|
||||
() => {
|
||||
this.processingCount = Math.max(0, this.processingCount - 1)
|
||||
this.isProcessing = this.processingCount > 0
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.eventListenersController?.abort()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import Sortable from 'sortablejs'
|
||||
window.Sortable = Sortable
|
||||
|
||||
export default (Alpine) => {
|
||||
Alpine.directive('sortable', (el) => {
|
||||
Alpine.directive('sortable', (el, {}, { cleanup }) => {
|
||||
let animation = parseInt(el.dataset?.sortableAnimationDuration)
|
||||
|
||||
if (animation !== 0 && !animation) {
|
||||
@@ -43,5 +43,7 @@ export default (Alpine) => {
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
cleanup(() => el.sortable.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ export class Select {
|
||||
this.searchTimeout = null
|
||||
this.isSearching = false
|
||||
this.maxItemsMessageElement = null
|
||||
this.badgesSortable = null
|
||||
// Version token to prevent race conditions when updating the selected display
|
||||
this.selectedDisplayVersion = 0
|
||||
|
||||
@@ -643,6 +644,12 @@ export class Select {
|
||||
|
||||
// Commit if still current
|
||||
if (renderVersion === this.selectedDisplayVersion) {
|
||||
if (
|
||||
!fragment.querySelector('.fi-select-input-value-badges-ctn')
|
||||
) {
|
||||
this.destroyBadgesSortable()
|
||||
}
|
||||
|
||||
this.selectedDisplay.replaceChildren(fragment)
|
||||
if (this.isOpen) {
|
||||
this.positionDropdown()
|
||||
@@ -659,6 +666,7 @@ export class Select {
|
||||
fragment.appendChild(placeholderSpan)
|
||||
|
||||
if (renderVersion === this.selectedDisplayVersion) {
|
||||
this.destroyBadgesSortable()
|
||||
this.selectedDisplay.replaceChildren(fragment)
|
||||
|
||||
// Remove the remove button since there's no selection
|
||||
@@ -679,6 +687,7 @@ export class Select {
|
||||
this.addSingleSelectionDisplay(selectedLabel, fragment)
|
||||
|
||||
if (renderVersion === this.selectedDisplayVersion) {
|
||||
this.destroyBadgesSortable()
|
||||
this.selectedDisplay.replaceChildren(fragment)
|
||||
}
|
||||
}
|
||||
@@ -881,7 +890,9 @@ export class Select {
|
||||
event.stopPropagation()
|
||||
})
|
||||
|
||||
new Sortable(badgesContainer, {
|
||||
this.destroyBadgesSortable()
|
||||
|
||||
this.badgesSortable = new Sortable(badgesContainer, {
|
||||
animation: 150,
|
||||
onEnd: () => {
|
||||
const newState = []
|
||||
@@ -899,6 +910,11 @@ export class Select {
|
||||
}
|
||||
}
|
||||
|
||||
destroyBadgesSortable() {
|
||||
this.badgesSortable?.destroy()
|
||||
this.badgesSortable = null
|
||||
}
|
||||
|
||||
// Helper method to get label for single selection
|
||||
async getLabelForSingleSelection() {
|
||||
// First check if we have the label in the repository
|
||||
@@ -2338,6 +2354,9 @@ export class Select {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.selectedDisplayVersion++
|
||||
this.destroyBadgesSortable()
|
||||
|
||||
// Remove button click event listener
|
||||
if (this.selectButton && this.buttonClickListener) {
|
||||
this.selectButton.removeEventListener(
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -34,15 +34,31 @@ export default ({
|
||||
|
||||
cleanUpFiltersDropdown: null,
|
||||
|
||||
livewireEventListenersController: null,
|
||||
|
||||
unsubscribeLivewireHook: null,
|
||||
|
||||
init() {
|
||||
this.livewireId =
|
||||
this.$root.closest('[wire\\:id]')?.attributes['wire:id'].value
|
||||
|
||||
$wire.$on('deselectAllTableRecords', () => this.deselectAllRecords())
|
||||
$wire.$on('scrollToTopOfTable', () =>
|
||||
this.$root.scrollIntoView({ block: 'start', inline: 'nearest' }),
|
||||
this.livewireEventListenersController = new AbortController()
|
||||
|
||||
const { signal } = this.livewireEventListenersController
|
||||
|
||||
$wire.$el.addEventListener(
|
||||
'deselectAllTableRecords',
|
||||
() => this.deselectAllRecords(),
|
||||
{ signal },
|
||||
)
|
||||
$wire.$el.addEventListener(
|
||||
'scrollToTopOfTable',
|
||||
() =>
|
||||
this.$root.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
}),
|
||||
{ signal },
|
||||
)
|
||||
|
||||
if (currentSelectionLivewireProperty) {
|
||||
@@ -390,30 +406,27 @@ export default ({
|
||||
this.areFiltersOpen = !this.areFiltersOpen
|
||||
|
||||
if (this.areFiltersOpen) {
|
||||
const cleanUpAutoUpdate = autoUpdate(
|
||||
this.$refs.filtersTriggerActionContainer,
|
||||
this.$refs.filtersContentContainer,
|
||||
async () => {
|
||||
const { x, y } = await computePosition(
|
||||
this.$refs.filtersTriggerActionContainer,
|
||||
this.$refs.filtersContentContainer,
|
||||
{
|
||||
placement: 'bottom-end',
|
||||
middleware: [offset(8), shift({ padding: 8 })],
|
||||
},
|
||||
)
|
||||
const trigger = this.$refs.filtersTriggerActionContainer
|
||||
const filters = this.$refs.filtersContentContainer
|
||||
let isPositioningActive = true
|
||||
|
||||
Object.assign(this.$refs.filtersContentContainer.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
})
|
||||
},
|
||||
)
|
||||
const cleanUpAutoUpdate = autoUpdate(trigger, filters, async () => {
|
||||
const { x, y } = await computePosition(trigger, filters, {
|
||||
placement: 'bottom-end',
|
||||
middleware: [offset(8), shift({ padding: 8 })],
|
||||
})
|
||||
|
||||
if (!isPositioningActive) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(filters.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
})
|
||||
})
|
||||
|
||||
const onClickAway = (event) => {
|
||||
const trigger = this.$refs.filtersTriggerActionContainer
|
||||
const filters = this.$refs.filtersContentContainer
|
||||
|
||||
if (
|
||||
(filters && filters.contains(event.target)) ||
|
||||
(trigger && trigger.contains(event.target))
|
||||
@@ -441,6 +454,7 @@ export default ({
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
|
||||
this.cleanUpFiltersDropdown = () => {
|
||||
isPositioningActive = false
|
||||
cleanUpAutoUpdate()
|
||||
document.removeEventListener('mousedown', onClickAway)
|
||||
document.removeEventListener('touchstart', onClickAway, {
|
||||
@@ -455,6 +469,8 @@ export default ({
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.cleanUpFiltersDropdown?.()
|
||||
this.livewireEventListenersController?.abort()
|
||||
this.unsubscribeLivewireHook?.()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -71,7 +71,10 @@ describe('browser interactions', function (): void {
|
||||
resolveLivewireComponentUsing: () => ({}),
|
||||
$wire: {
|
||||
mountedActions: [{}],
|
||||
__instance: { effects: {} },
|
||||
__instance: {
|
||||
effects: {},
|
||||
addCleanup: () => {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class ColorPickerTest extends Page
|
||||
->schema([
|
||||
ColorPicker::make('field')
|
||||
->label('Test Color')
|
||||
->live(debounce: 1000)
|
||||
->extraAttributes(['data-testid' => 'color-picker']),
|
||||
])
|
||||
->statePath('data');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Filament\Tests\Fixtures\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedTable;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -17,6 +18,8 @@ class IndividualColumnSearchBrowserTest extends Page implements HasTable
|
||||
{
|
||||
use Tables\Concerns\InteractsWithTable;
|
||||
|
||||
public bool $showTable = true;
|
||||
|
||||
protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedMagnifyingGlass;
|
||||
|
||||
protected static ?int $navigationSort = 9;
|
||||
@@ -46,7 +49,17 @@ class IndividualColumnSearchBrowserTest extends Page implements HasTable
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
EmbeddedTable::make(),
|
||||
EmbeddedTable::make()
|
||||
->visible(fn (): bool => $this->showTable),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('toggleTable')
|
||||
->action(fn () => $this->showTable = ! $this->showTable)
|
||||
->extraAttributes(['data-testid' => 'toggle-table']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,51 @@ it('can render `ColorPicker` in the browser', function (): void {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not run delayed callbacks after `ColorPicker` is destroyed', function (): void {
|
||||
retry(10, function (): void {
|
||||
Artisan::call('filament:assets');
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/color-picker-test');
|
||||
|
||||
$page->assertScript('document.querySelector(\'[data-testid="color-picker"] [x-data]:not([x-ignore])\') !== null', true);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
window.colorPickerCommitCount = 0
|
||||
window.removeColorPickerCommitHook = Livewire.hook('commit', () => window.colorPickerCommitCount++)
|
||||
|
||||
const colorPicker = document.querySelector('[data-testid="color-picker"] [x-data]')
|
||||
const input = colorPicker.querySelector('input')
|
||||
|
||||
input.value = '#123456'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
colorPicker.querySelector('[x-ref="panel"]').dispatchEvent(new CustomEvent('color-changed', {
|
||||
bubbles: true,
|
||||
detail: { value: '#123456' },
|
||||
}))
|
||||
|
||||
setTimeout(() => colorPicker.remove())
|
||||
JS);
|
||||
|
||||
$page
|
||||
->wait(2)
|
||||
->assertMissing('[data-testid="color-picker"] [x-data]')
|
||||
->assertScript('window.colorPickerCommitCount', 0)
|
||||
->assertNoSmoke();
|
||||
|
||||
$page->script('window.removeColorPickerCommitHook()');
|
||||
|
||||
visit('/color-picker-test')
|
||||
->assertNoAccessibilityIssues();
|
||||
|
||||
visit('/color-picker-test')
|
||||
->inDarkMode()
|
||||
->assertNoAccessibilityIssues();
|
||||
});
|
||||
});
|
||||
|
||||
class RenderColorPickerWithHsl extends Livewire
|
||||
{
|
||||
public function form(Schema $form): Schema
|
||||
|
||||
@@ -490,6 +490,58 @@ describe('searching', function (): void {
|
||||
});
|
||||
});
|
||||
|
||||
it('removes table event listeners when the table is destroyed', function (): void {
|
||||
retry(10, function (): void {
|
||||
Artisan::call('filament:assets');
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/individual-column-search-browser-test');
|
||||
|
||||
$page->script(<<<'JS'
|
||||
window.tableScrollCount = 0
|
||||
if (! window.originalScrollIntoView) {
|
||||
window.originalScrollIntoView = Element.prototype.scrollIntoView
|
||||
Element.prototype.scrollIntoView = function (...arguments) {
|
||||
if (this.matches?.('.fi-ta')) {
|
||||
window.tableScrollCount++
|
||||
}
|
||||
|
||||
return window.originalScrollIntoView.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
JS);
|
||||
|
||||
$page
|
||||
->click('[data-testid="toggle-table"]')
|
||||
->wait(1)
|
||||
->assertMissing('.fi-ta')
|
||||
->click('[data-testid="toggle-table"]')
|
||||
->wait(1)
|
||||
->assertPresent('.fi-ta')
|
||||
->click('[data-testid="toggle-table"]')
|
||||
->wait(1)
|
||||
->assertMissing('.fi-ta')
|
||||
->click('[data-testid="toggle-table"]')
|
||||
->wait(1)
|
||||
->assertPresent('.fi-ta');
|
||||
|
||||
$page->script(<<<'JS'
|
||||
window.tableScrollCount = 0
|
||||
document.querySelector('.fi-ta').closest('[wire\\:id]').dispatchEvent(new CustomEvent('scrollToTopOfTable'))
|
||||
JS);
|
||||
|
||||
$page
|
||||
->assertScript('window.tableScrollCount', 1)
|
||||
->assertNoSmoke()
|
||||
->assertNoAccessibilityIssues();
|
||||
|
||||
visit('/individual-column-search-browser-test')
|
||||
->inDarkMode()
|
||||
->assertNoAccessibilityIssues();
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes column manager checkbox ids to each table in the browser', function (): void {
|
||||
retry(10, function (): void {
|
||||
Artisan::call('filament:assets');
|
||||
|
||||
Reference in New Issue
Block a user