fix: clean up JavaScript resources on destruction

This commit is contained in:
Dan Harrin
2026-09-19 10:50:51 +00:00
parent 4bbc9c092d
commit 95fdf86ebb
30 changed files with 554 additions and 194 deletions
+6
View File
@@ -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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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(
@@ -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()
},
}
}
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?.()
},
}))
+1 -1
View File
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),
)
}
+2 -2
View File
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 -1
View File
@@ -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(
File diff suppressed because one or more lines are too long
+1 -1
View File
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?.()
},
})
+4 -1
View File
@@ -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
+52
View File
@@ -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');