From bbe0ba6ad6050a285414a02208b58bda7db31026 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Mon, 8 Jun 2026 17:23:33 -0400 Subject: [PATCH 1/2] feat(react,vue): add RowDetail portal/teleport host context propagation --- .../src/RowDetailPortalHost.tsx | 55 ++++++++ .../react-row-detail-plugin/src/index.ts | 1 + .../src/reactRowDetailView.ts | 116 +++++++++++++--- .../react-row-detail-plugin/tsconfig.json | 1 + .../src/RowDetailTeleportHost.ts | 54 ++++++++ .../vue-row-detail-plugin/src/index.ts | 1 + .../src/vueRowDetailView.ts | 131 ++++++++++++++---- 7 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx create mode 100644 frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts diff --git a/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx b/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx new file mode 100644 index 0000000000..a3b13d70a1 --- /dev/null +++ b/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { createPortal } from 'react-dom'; +import type { PortalEntry, ReactRowDetailView } from './reactRowDetailView.js'; + +export type { PortalEntry }; + +export interface RowDetailPortalHostProps { + plugin: ReactRowDetailView; +} + +/** + * RowDetailPortalHost — place this component once in your app tree (e.g. alongside your SlickGrid component). + * It renders each open row detail via React.createPortal, keeping every detail panel inside the app's + * React component tree so that Context, Redux, Zustand, and other providers are fully accessible. + * + * Without this host, the plugin falls back to creating an isolated `createRoot()` per row detail, + * which breaks context propagation and produces a React dev warning on every render. + * + * @example + * ```tsx + * import { ReactRowDetailView } from '@slickgrid-universal/react-row-detail-plugin'; + * import { RowDetailPortalHost } from '@slickgrid-universal/react-row-detail-plugin'; + * + * // create the plugin instance once + * const rowDetailPlugin = new ReactRowDetailView(eventPubSubService); + * + * // place the host anywhere in the same React tree as your grid (e.g. same component) + * return ( + * <> + * + * + * + * ); + * ``` + */ +export function RowDetailPortalHost({ plugin }: RowDetailPortalHostProps): ReactElement { + const [entries, setEntries] = useState([]); + + useEffect(() => { + plugin.registerPortalHost(setEntries); + return () => { + plugin.registerPortalHost(undefined); + }; + }, [plugin]); + + return ( + <> + {entries.map((entry) => { + const Component = entry.component; + return createPortal(, entry.container, String(entry.id)); + })} + + ); +} diff --git a/frameworks-plugins/react-row-detail-plugin/src/index.ts b/frameworks-plugins/react-row-detail-plugin/src/index.ts index 4383913d17..ad49a61272 100644 --- a/frameworks-plugins/react-row-detail-plugin/src/index.ts +++ b/frameworks-plugins/react-row-detail-plugin/src/index.ts @@ -1 +1,2 @@ export * from './reactRowDetailView.js'; +export * from './RowDetailPortalHost.js'; diff --git a/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts b/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts index 349dce4e36..4d5afa0d74 100644 --- a/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts +++ b/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts @@ -12,6 +12,7 @@ import { } from '@slickgrid-universal/common'; import { type EventPubSubService } from '@slickgrid-universal/event-pub-sub'; import { SlickRowDetailView as UniversalSlickRowDetailView } from '@slickgrid-universal/row-detail-view-plugin'; +import type { ComponentType } from 'react'; import type { Root } from 'react-dom/client'; import { createReactComponentDynamically, type GridOption, type ViewModelBindableInputData } from 'slickgrid-react'; import type { RowDetailView } from './interfaces.js'; @@ -19,6 +20,13 @@ import type { RowDetailView } from './interfaces.js'; const ROW_DETAIL_CONTAINER_PREFIX = 'container_'; const PRELOAD_CONTAINER_PREFIX = 'container_loading'; +export interface PortalEntry { + id: string | number; + container: Element; + component: ComponentType; + data: ViewModelBindableInputData; +} + export interface CreatedView { id: string | number; dataContext: any; @@ -37,6 +45,8 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { protected gridContainerElement!: HTMLElement; protected _timer?: any; _root?: Root; + protected _portalEntries: PortalEntry[] = []; + protected _portalHostSetter?: (entries: PortalEntry[]) => void; constructor(private readonly eventPubSubService: EventPubSubService) { super(eventPubSubService); @@ -68,6 +78,13 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { /** Dispose of all the opened Row Detail Panels Components */ disposeAllViewComponents() { + if (this._portalHostSetter) { + // Portal mode: batch-clear all entries in a single state update + this._portalEntries = []; + this._portalHostSetter([]); + this._views = []; + return; + } do { const view = this._views.pop(); if (view) { @@ -76,6 +93,19 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { } while (this._views.length > 0); } + /** + * Register the RowDetailPortalHost component's state setter. + * When provided, row details render via React portals (staying inside the app's React tree for proper + * context propagation). When undefined the plugin falls back to the legacy isolated createRoot approach. + * @internal — called by RowDetailPortalHost, not intended for direct use. + */ + registerPortalHost(setter: ((entries: PortalEntry[]) => void) | undefined): void { + this._portalHostSetter = setter; + if (setter) { + setter([...this._portalEntries]); + } + } + /** Get the instance of the SlickGrid addon (control or plugin). */ getAddonInstance(): ReactRowDetailView | null { return this; @@ -137,8 +167,12 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { }); this._eventHandler.subscribe(this.onAsyncEndUpdate, async (event, args) => { - // dispose preload if exists - this._preloadRoot?.unmount(); + // dispose preload — either portal entry or legacy root + if (this._portalHostSetter) { + this._removePortalEntry('__preload__'); + } else { + this._preloadRoot?.unmount(); + } // triggers after backend called "onAsyncResponse.notify()" // because of the preload destroy above, we need a small delay to make sure the DOM element is ready to render the Row Detail @@ -254,11 +288,10 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { } } - /** Render (or re-render) the View Component (Row Detail) */ + /** Render (or re-render) the preload View Component (Row Detail) */ renderPreloadView(item: any) { const containerElement = this.gridContainerElement.querySelector(`.${PRELOAD_CONTAINER_PREFIX}`); if (this._preloadComponent && containerElement) { - // render row detail const bindableData = { model: item, addon: this, @@ -266,11 +299,17 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { dataView: this.dataView, parentRef: this.rowDetailViewOptions?.parentRef, } as ViewModelBindableInputData; - const detailContainer = document.createElement('section'); - containerElement.appendChild(detailContainer); - const { root } = createReactComponentDynamically(this._preloadComponent, detailContainer, bindableData); - this._preloadRoot = root; + if (this._portalHostSetter) { + // Portal mode: render preload via RowDetailPortalHost (keeps React context/providers) + this._updatePortalEntry({ id: '__preload__', container: containerElement, component: this._preloadComponent, data: bindableData }); + } else { + // Legacy mode: create isolated React root + const detailContainer = document.createElement('section'); + containerElement.appendChild(detailContainer); + const { root } = createReactComponentDynamically(this._preloadComponent, detailContainer, bindableData); + this._preloadRoot = root; + } } } @@ -288,16 +327,32 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { parentRef: this.rowDetailViewOptions?.parentRef, } as ViewModelBindableInputData; - // load our Row Detail React Component dynamically, typically we would want to use `root.render()` after the preload component (last argument below) - // BUT the root render doesn't seem to work and shows a blank element, so we'll use `createRoot()` every time even though it shows a console log in Dev - // that is the only way I got it working so let's use it anyway and console warnings are removed in production anyway - const viewObj = this._views.find((obj) => obj.id === item[this.datasetIdPropName]); - const { root } = createReactComponentDynamically(this._component, containerElement, bindableData); - if (viewObj) { - viewObj.root = root; - viewObj.rendered = true; + if (this._portalHostSetter) { + // Portal mode: render via RowDetailPortalHost so this panel stays inside the app's React tree, + // giving it full access to Context, Redux, Zustand, and other providers. + const viewObj = this._views.find((obj) => obj.id === item[this.datasetIdPropName]); + this._updatePortalEntry({ + id: item[this.datasetIdPropName], + container: containerElement, + component: this._component, + data: bindableData, + }); + if (viewObj) { + viewObj.rendered = true; + } else { + this.upsertViewRefs(item, null); + } } else { - this.upsertViewRefs(item, root); + // Legacy mode: each row detail gets its own isolated React root. + // Note: createRoot() is used every time since root.render() on a pre-existing root shows a blank element. + const viewObj = this._views.find((obj) => obj.id === item[this.datasetIdPropName]); + const { root } = createReactComponentDynamically(this._component, containerElement, bindableData); + if (viewObj) { + viewObj.root = root; + viewObj.rendered = true; + } else { + this.upsertViewRefs(item, root); + } } } } @@ -334,6 +389,13 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { protected disposeViewComponent(expandedView: CreatedView): CreatedView | void { expandedView.rendered = false; + + if (this._portalHostSetter) { + // Portal mode: remove the portal entry — RowDetailPortalHost will unmount the component + this._removePortalEntry(expandedView.id); + return expandedView; + } + if (expandedView?.root) { const container = this.gridContainerElement.querySelector(`.${ROW_DETAIL_CONTAINER_PREFIX}${expandedView.id}`); if (container) { @@ -344,6 +406,26 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { } } + /** Add or update a portal entry and notify the host setter */ + protected _updatePortalEntry(entry: PortalEntry): void { + const idx = this._portalEntries.findIndex((e) => e.id === entry.id); + if (idx >= 0) { + this._portalEntries[idx] = entry; + } else { + this._portalEntries.push(entry); + } + this._portalHostSetter?.([...this._portalEntries]); + } + + /** Remove a portal entry by id and notify the host setter */ + protected _removePortalEntry(id: string | number): void { + const idx = this._portalEntries.findIndex((e) => e.id === id); + if (idx >= 0) { + this._portalEntries.splice(idx, 1); + this._portalHostSetter?.([...this._portalEntries]); + } + } + /** * Just before the row get expanded or collapsed we will do the following * First determine if the row is expanding or collapsing, diff --git a/frameworks-plugins/react-row-detail-plugin/tsconfig.json b/frameworks-plugins/react-row-detail-plugin/tsconfig.json index 06660a9f5e..fd0c8cffdb 100644 --- a/frameworks-plugins/react-row-detail-plugin/tsconfig.json +++ b/frameworks-plugins/react-row-detail-plugin/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "pretty": true, + "jsx": "react-jsx", "module": "esnext", "moduleResolution": "bundler", "target": "es2022", diff --git a/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts b/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts new file mode 100644 index 0000000000..af6114afa2 --- /dev/null +++ b/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts @@ -0,0 +1,54 @@ +import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, Teleport } from 'vue'; +import type { PropType } from 'vue'; +import type { TeleportEntry, VueRowDetailView } from './vueRowDetailView.js'; + +/** + * RowDetailTeleportHost — place this component once in your app template (e.g. alongside your SlickGrid component). + * It renders each open row detail via Vue's built-in ``, keeping every detail panel inside the Vue + * component tree so that provide/inject, Pinia, Vue Router, and other providers are fully accessible. + * + * Without this host, the plugin falls back to creating an isolated `createApp()` per row detail, + * which breaks provide/inject chains and Pinia store access. + * + * @example + * ```ts + * import { VueRowDetailView } from '@slickgrid-universal/vue-row-detail-plugin'; + * import { RowDetailTeleportHost } from '@slickgrid-universal/vue-row-detail-plugin'; + * + * // create the plugin instance once + * const rowDetailPlugin = new VueRowDetailView(eventPubSubService); + * ``` + * + * ```html + * + * + * + * ``` + */ +export const RowDetailTeleportHost = defineComponent({ + name: 'RowDetailTeleportHost', + props: { + plugin: { + type: Object as PropType, + required: true, + }, + }, + setup(props) { + const entries = shallowRef([]); + + onMounted(() => { + props.plugin.registerTeleportHost((newEntries) => { + entries.value = [...newEntries]; + }); + }); + + onBeforeUnmount(() => { + props.plugin.registerTeleportHost(undefined); + }); + + return () => + entries.value.map((entry) => + h(Teleport as any, { to: entry.selector, key: String(entry.id) }, () => h(entry.component as any, entry.data)) + ); + }, +}); diff --git a/frameworks-plugins/vue-row-detail-plugin/src/index.ts b/frameworks-plugins/vue-row-detail-plugin/src/index.ts index 9c9ff6067c..cac25cbf1e 100644 --- a/frameworks-plugins/vue-row-detail-plugin/src/index.ts +++ b/frameworks-plugins/vue-row-detail-plugin/src/index.ts @@ -1 +1,2 @@ export * from './vueRowDetailView.js'; +export * from './RowDetailTeleportHost.js'; diff --git a/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts b/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts index ff12fd5491..f3dbbb05c9 100644 --- a/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts +++ b/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts @@ -20,6 +20,14 @@ const ROW_DETAIL_CONTAINER_PREFIX = 'container_'; const PRELOAD_CONTAINER_PREFIX = 'container_loading'; type AppData = Record; + +export interface TeleportEntry { + id: string | number; + selector: string; + component: any; + data: ViewModelBindableInputData; +} + export interface CreatedView { id: string | number; dataContext: any; @@ -38,6 +46,8 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { protected _userProcessFn?: (item: any) => Promise; protected gridContainerElement!: HTMLElement; protected _timer?: any; + protected _teleportEntries: TeleportEntry[] = []; + protected _teleportHostSetter?: (entries: TeleportEntry[]) => void; constructor(private readonly eventPubSubService: EventPubSubService) { super(eventPubSubService); @@ -67,8 +77,28 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { super.dispose(); } + /** + * Register the RowDetailTeleportHost component's state setter. + * When provided, row details render via Vue Teleport (staying inside the app's Vue component tree for proper + * provide/inject, Pinia, and Vue Router access). When undefined the plugin falls back to the legacy isolated createApp approach. + * @internal — called by RowDetailTeleportHost, not intended for direct use. + */ + registerTeleportHost(setter: ((entries: TeleportEntry[]) => void) | undefined): void { + this._teleportHostSetter = setter; + if (setter) { + setter([...this._teleportEntries]); + } + } + /** Dispose of all the opened Row Detail Panels Components */ disposeAllViewComponents() { + if (this._teleportHostSetter) { + // Teleport mode: batch-clear all entries in a single state update + this._teleportEntries = []; + this._teleportHostSetter([]); + this._views = []; + return; + } do { const view = this._views.pop(); if (view) { @@ -148,8 +178,12 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { }); this._eventHandler.subscribe(this.onAsyncEndUpdate, async (event, args) => { - // unmount preload if exists - this._preloadApp?.unmount(); + // unmount preload — either teleport entry or legacy app + if (this._teleportHostSetter) { + this._removeTeleportEntry('__preload__'); + } else { + this._preloadApp?.unmount(); + } // triggers after backend called "onAsyncResponse.notify()" // because of the preload destroy above, we need a small delay to make sure the DOM element is ready to render the Row Detail @@ -267,11 +301,10 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { } } - /** Render (or re-render) the View Component (Row Detail) */ + /** Render (or re-render) the preload View Component (Row Detail) */ renderPreloadView(item: any) { const containerElement = this.gridContainerElement.querySelector(`.${PRELOAD_CONTAINER_PREFIX}`); if (this._preloadComponent && containerElement) { - const viewObj = this._views.find((obj) => obj.id === item[this.datasetIdPropName]); const bindableData = { model: item, addon: this, @@ -280,15 +313,26 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { parentRef: this.rowDetailViewOptions?.parentRef, } as AppData & ViewModelBindableInputData; - const tmpDiv = document.createElement('div'); - this._preloadApp = createApp(this._preloadComponent, bindableData); - const instance = this._preloadApp.mount(tmpDiv) as ComponentPublicInstance; - bindableData.parentRef = instance; - containerElement.appendChild(instance.$el); - - if (viewObj) { - viewObj.app = this._preloadApp; - viewObj.instance = instance; + if (this._teleportHostSetter) { + // Teleport mode: render preload via RowDetailTeleportHost (keeps Vue context/providers) + this._updateTeleportEntry({ + id: '__preload__', + selector: `.${PRELOAD_CONTAINER_PREFIX}`, + component: this._preloadComponent, + data: bindableData as ViewModelBindableInputData, + }); + } else { + // Legacy mode: isolated createApp per preload + const viewObj = this._views.find((obj) => obj.id === item[this.datasetIdPropName]); + const tmpDiv = document.createElement('div'); + this._preloadApp = createApp(this._preloadComponent, bindableData); + const instance = this._preloadApp.mount(tmpDiv) as ComponentPublicInstance; + bindableData.parentRef = instance; + containerElement.appendChild(instance.$el); + if (viewObj) { + viewObj.app = this._preloadApp; + viewObj.instance = instance; + } } } } @@ -305,16 +349,28 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { parentRef: this.rowDetailViewOptions?.parentRef, } as AppData & ViewModelBindableInputData; - this.unmountViewWhenExists(item[this.datasetIdPropName]); - - // empty container & load our Row Detail Vue Component dynamically - emptyElement(containerElement); - const tmpDiv = document.createElement('div'); - const app = createApp(this._component, bindableData); - const instance = app.mount(tmpDiv) as ComponentPublicInstance; - bindableData.parentRef = app.component; - containerElement.appendChild(instance.$el); - this.upsertViewRefs(item, { app, instance, rendered: true }); + if (this._teleportHostSetter) { + // Teleport mode: render via RowDetailTeleportHost so this panel stays inside the Vue component tree, + // giving it full access to provide/inject, Pinia, Vue Router, and other providers. + const selector = `.${ROW_DETAIL_CONTAINER_PREFIX}${item[this.datasetIdPropName]}`; + this._updateTeleportEntry({ + id: item[this.datasetIdPropName], + selector, + component: this._component, + data: bindableData as ViewModelBindableInputData, + }); + this.upsertViewRefs(item, { app: null, instance: null, rendered: true }); + } else { + // Legacy mode: isolated createApp per row detail + this.unmountViewWhenExists(item[this.datasetIdPropName]); + emptyElement(containerElement); + const tmpDiv = document.createElement('div'); + const app = createApp(this._component, bindableData); + const instance = app.mount(tmpDiv) as ComponentPublicInstance; + bindableData.parentRef = app.component; + containerElement.appendChild(instance.$el); + this.upsertViewRefs(item, { app, instance, rendered: true }); + } } } @@ -349,8 +405,15 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { } protected disposeViewComponent(expandedView: CreatedView): CreatedView | void { + expandedView.rendered = false; + + if (this._teleportHostSetter) { + // Teleport mode: remove the teleport entry — RowDetailTeleportHost will unmount the component + this._removeTeleportEntry(expandedView.id); + return expandedView; + } + if (expandedView?.instance) { - expandedView.rendered = false; const container = this.gridContainerElement.querySelector(`.${ROW_DETAIL_CONTAINER_PREFIX}${expandedView.id}`); if (container) { expandedView.app?.unmount(); @@ -360,6 +423,26 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { } } + /** Add or update a teleport entry and notify the host setter */ + protected _updateTeleportEntry(entry: TeleportEntry): void { + const idx = this._teleportEntries.findIndex((e) => e.id === entry.id); + if (idx >= 0) { + this._teleportEntries[idx] = entry; + } else { + this._teleportEntries.push(entry); + } + this._teleportHostSetter?.([...this._teleportEntries]); + } + + /** Remove a teleport entry by id and notify the host setter */ + protected _removeTeleportEntry(id: string | number): void { + const idx = this._teleportEntries.findIndex((e) => e.id === id); + if (idx >= 0) { + this._teleportEntries.splice(idx, 1); + this._teleportHostSetter?.([...this._teleportEntries]); + } + } + /** * Just before the row get expanded or collapsed we will do the following * First determine if the row is expanding or collapsing, From a510e745e37ada7c041d2445bcfbc8a412cd032e Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 9 Jun 2026 09:23:24 -0400 Subject: [PATCH 2/2] chore: use the Portal/Teleport Host in all Row Detail examples --- .../src/examples/slickgrid/Example19.tsx | 5 +- .../slickgrid/Example45-detail-view.tsx | 12 ++- .../src/examples/slickgrid/Example45.tsx | 6 +- .../src/examples/slickgrid/Example47.tsx | 5 +- demos/vue/src/components/Example19.vue | 37 +++++---- demos/vue/src/components/Example19Detail.vue | 9 ++- demos/vue/src/components/Example45.vue | 30 ++++--- demos/vue/src/components/Example45Detail.vue | 18 ++++- demos/vue/src/components/Example47.vue | 42 +++++----- .../src/RowDetailPortalHost.tsx | 3 +- .../src/reactRowDetailView.ts | 30 +++++++ .../src/RowDetailTeleportHost.ts | 81 +++++++++++++++++-- .../src/vueRowDetailView.ts | 36 +++++++++ .../docs/grid-functionalities/row-detail.md | 17 ++++ .../docs/grid-functionalities/row-detail.md | 17 ++++ .../src/slickRowDetailView.spec.ts | 28 +++++++ .../src/slickRowDetailView.ts | 4 + 17 files changed, 316 insertions(+), 64 deletions(-) diff --git a/demos/react/src/examples/slickgrid/Example19.tsx b/demos/react/src/examples/slickgrid/Example19.tsx index 902e847645..980e8114cd 100644 --- a/demos/react/src/examples/slickgrid/Example19.tsx +++ b/demos/react/src/examples/slickgrid/Example19.tsx @@ -1,5 +1,5 @@ import { type EventPubSubService } from '@slickgrid-universal/event-pub-sub'; -import { ReactRowDetailView } from '@slickgrid-universal/react-row-detail-plugin'; +import { ReactRowDetailView, RowDetailPortalHost } from '@slickgrid-universal/react-row-detail-plugin'; import React, { useEffect, useRef, useState } from 'react'; import { Editors, Filters, Formatters, SlickgridReact, type Column, type GridOption, type SlickgridReactInstance } from 'slickgrid-react'; import { ExampleDetailPreload } from './Example-detail-preload.js'; @@ -21,6 +21,7 @@ const Example19: React.FC = () => { const serverWaitDelayRef = useRef(serverWaitDelay); const reactGridRef = useRef(null); + const [rowDetailPlugin, setRowDetailPlugin] = useState(null); useEffect(() => { defineGrid(); @@ -165,6 +166,7 @@ const Example19: React.FC = () => { datasetIdPropertyName: 'rowId', preRegisterExternalExtensions: (pubSubService) => { const rowDetail = new ReactRowDetailView(pubSubService as EventPubSubService); + setRowDetailPlugin(rowDetail); return [{ name: 'rowDetailView', instance: rowDetail }]; }, rowDetailView: { @@ -377,6 +379,7 @@ const Example19: React.FC = () => { dataset={dataset} onReactGridCreated={($event) => (reactGridRef.current = $event.detail)} /> + {rowDetailPlugin ? : null} ); diff --git a/demos/react/src/examples/slickgrid/Example45-detail-view.tsx b/demos/react/src/examples/slickgrid/Example45-detail-view.tsx index ba9f42e53e..b666d9285f 100644 --- a/demos/react/src/examples/slickgrid/Example45-detail-view.tsx +++ b/demos/react/src/examples/slickgrid/Example45-detail-view.tsx @@ -67,8 +67,12 @@ const Example45DetailView: React.FC { const reactGridRef = useRef(null); const isUsingAutoHeightRef = useRef(isUsingAutoHeight); const isUsingInnerGridStatePresetsRef = useRef(isUsingInnerGridStatePresets); + const [rowDetailPlugin, setRowDetailPlugin] = useState(null); useEffect(() => { defineGrid(); @@ -159,6 +160,7 @@ const Example45: React.FC = () => { darkMode, preRegisterExternalExtensions: (pubSubService) => { const rowDetail = new ReactRowDetailView(pubSubService as EventPubSubService); + setRowDetailPlugin(rowDetail); return [{ name: 'rowDetailView', instance: rowDetail }]; }, rowDetailView: { @@ -211,7 +213,6 @@ const Example45: React.FC = () => { setIsUsingAutoHeight(newIsUsingAutoHeight); reactGridRef.current?.slickGrid?.setOptions({ autoResize: { ...gridOptions?.autoResize, autoHeight: newIsUsingAutoHeight } }); reactGridRef.current?.resizerService.resizeGrid(); - console.log('auto-height', reactGridRef.current?.slickGrid.getOptions()); return true; } @@ -376,6 +377,7 @@ const Example45: React.FC = () => { dataset={dataset} onReactGridCreated={($event) => (reactGridRef.current = $event.detail)} /> + {rowDetailPlugin ? : null} ); diff --git a/demos/react/src/examples/slickgrid/Example47.tsx b/demos/react/src/examples/slickgrid/Example47.tsx index 6170cdcbcb..652a912824 100644 --- a/demos/react/src/examples/slickgrid/Example47.tsx +++ b/demos/react/src/examples/slickgrid/Example47.tsx @@ -1,5 +1,5 @@ import { type EventPubSubService } from '@slickgrid-universal/event-pub-sub'; -import { ReactRowDetailView } from '@slickgrid-universal/react-row-detail-plugin'; +import { ReactRowDetailView, RowDetailPortalHost } from '@slickgrid-universal/react-row-detail-plugin'; import React, { useEffect, useRef, useState } from 'react'; import { Aggregators, @@ -44,6 +44,7 @@ const Example47: React.FC = () => { const serverWaitDelayRef = useRef(serverWaitDelay); const reactGridRef = useRef(null); + const [rowDetailPlugin, setRowDetailPlugin] = useState(null); useEffect(() => { defineGrid(); @@ -201,6 +202,7 @@ const Example47: React.FC = () => { darkMode, preRegisterExternalExtensions: (pubSubService) => { const rowDetail = new ReactRowDetailView(pubSubService as EventPubSubService); + setRowDetailPlugin(rowDetail); return [{ name: 'rowDetailView', instance: rowDetail }]; }, rowDetailView: { @@ -465,6 +467,7 @@ const Example47: React.FC = () => { dataset={dataset} onReactGridCreated={($event) => reactGridReady($event.detail)} /> + {rowDetailPlugin ? : null} ); diff --git a/demos/vue/src/components/Example19.vue b/demos/vue/src/components/Example19.vue index b9239270c0..ec8eaee4d9 100644 --- a/demos/vue/src/components/Example19.vue +++ b/demos/vue/src/components/Example19.vue @@ -1,11 +1,11 @@ @@ -341,5 +344,6 @@ function vueGridReady(grid: SlickgridVueInstance) { @onVueGridCreated="vueGridReady($event.detail)" > + diff --git a/demos/vue/src/components/Example45Detail.vue b/demos/vue/src/components/Example45Detail.vue index 42a26e6d15..da61bf2c4d 100644 --- a/demos/vue/src/components/Example45Detail.vue +++ b/demos/vue/src/components/Example45Detail.vue @@ -49,12 +49,20 @@ onBeforeUnmount(() => { onMounted(() => { innerDataset.value = [...props.model.orderData]; showGrid.value = true; + console.log('Example45Detail mounted for id:', props.model.id); + // mark the container so tests can detect the detail has mounted + const container = document.querySelector(`.container_${props.model.id}`) as HTMLElement | null; + if (container) { + container.dataset.detailMounted = '1'; + } }); function handleBeforeGridDestroy() { if (props.model.isUsingInnerGridStatePresets) { const gridState = vueGrid.gridStateService.getCurrentGridState(); - sessionStorage.setItem(`gridstate_${innerGridClass.value}`, JSON.stringify(gridState)); + if (gridState) { + sessionStorage.setItem(`gridstate_${innerGridClass.value}`, JSON.stringify(gridState)); + } } } @@ -63,8 +71,12 @@ function defineGrid() { let gridState: GridState | undefined; if (props.model.isUsingInnerGridStatePresets) { const gridStateStr = sessionStorage.getItem(`gridstate_${innerGridClass.value}`); - if (gridStateStr) { - gridState = JSON.parse(gridStateStr); + if (gridStateStr && gridStateStr !== 'undefined') { + try { + gridState = JSON.parse(gridStateStr); + } catch { + // ignore malformed JSON + } } } diff --git a/demos/vue/src/components/Example47.vue b/demos/vue/src/components/Example47.vue index 095894a63b..5b0d201727 100644 --- a/demos/vue/src/components/Example47.vue +++ b/demos/vue/src/components/Example47.vue @@ -1,5 +1,5 @@ @@ -435,5 +438,6 @@ function vueGridReady(grid: SlickgridVueInstance) { @onVueGridCreated="vueGridReady($event.detail)" > + diff --git a/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx b/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx index a3b13d70a1..cb80629ab1 100644 --- a/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx +++ b/frameworks-plugins/react-row-detail-plugin/src/RowDetailPortalHost.tsx @@ -48,7 +48,8 @@ export function RowDetailPortalHost({ plugin }: RowDetailPortalHostProps): React <> {entries.map((entry) => { const Component = entry.component; - return createPortal(, entry.container, String(entry.id)); + const key = `${String(entry.id)}-${entry.gen ?? 0}`; + return createPortal(, entry.container, key); })} ); diff --git a/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts b/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts index 4d5afa0d74..a1d1ce23f3 100644 --- a/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts +++ b/frameworks-plugins/react-row-detail-plugin/src/reactRowDetailView.ts @@ -25,6 +25,7 @@ export interface PortalEntry { container: Element; component: ComponentType; data: ViewModelBindableInputData; + gen?: number; } export interface CreatedView { @@ -47,6 +48,10 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { _root?: Root; protected _portalEntries: PortalEntry[] = []; protected _portalHostSetter?: (entries: PortalEntry[]) => void; + /** Monotonically-increasing generation counter per entry id — never resets on removal so React key always changes on re-add */ + protected _portalGenMap: Map = new Map(); + /** Tracks the last container Element handed to React per entry id — survives removal so we can detect new DOM nodes (loadOnce restored HTML) vs same-DOM re-renders */ + protected _portalContainerMap: Map = new Map(); constructor(private readonly eventPubSubService: EventPubSubService) { super(eventPubSubService); @@ -83,6 +88,7 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { this._portalEntries = []; this._portalHostSetter([]); this._views = []; + this._portalContainerMap.clear(); return; } do { @@ -302,6 +308,12 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { if (this._portalHostSetter) { // Portal mode: render preload via RowDetailPortalHost (keeps React context/providers) + // Clear stale HTML when the container is a new DOM node (e.g. loadOnce restored saved HTML via innerHTML). + // Do NOT clear if it's the same DOM node React is already managing — that causes removeChild errors. + const prevPreloadContainer = this._portalContainerMap.get('__preload__'); + if (!prevPreloadContainer || containerElement !== prevPreloadContainer) { + containerElement.textContent = ''; + } this._updatePortalEntry({ id: '__preload__', container: containerElement, component: this._preloadComponent, data: bindableData }); } else { // Legacy mode: create isolated React root @@ -331,6 +343,12 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { // Portal mode: render via RowDetailPortalHost so this panel stays inside the app's React tree, // giving it full access to Context, Redux, Zustand, and other providers. const viewObj = this._views.find((obj) => obj.id === item[this.datasetIdPropName]); + // Clear stale HTML when the container is a new DOM node (e.g. loadOnce restored saved HTML via innerHTML). + // Do NOT clear if it's the same DOM node React is already managing — that causes removeChild errors. + const prevContainer = this._portalContainerMap.get(item[this.datasetIdPropName]); + if (!prevContainer || containerElement !== prevContainer) { + containerElement.textContent = ''; + } this._updatePortalEntry({ id: item[this.datasetIdPropName], container: containerElement, @@ -410,10 +428,22 @@ export class ReactRowDetailView extends UniversalSlickRowDetailView { protected _updatePortalEntry(entry: PortalEntry): void { const idx = this._portalEntries.findIndex((e) => e.id === entry.id); if (idx >= 0) { + // Entry already exists in the live list — preserve its generation so React keeps the same key + // and only patches props (no remount, no loss of inner component state like filter inputs). + entry.gen = this._portalEntries[idx].gen; this._portalEntries[idx] = entry; } else { + // Entry is new or was previously removed. Bump the per-id generation counter so the host + // key changes (e.g. "1-0" → "1-1"). This is critical for React 18 automatic batching: + // if remove + re-add happen in the same flush, the intermediate empty state is never + // committed; only a key change guarantees React unmounts the old and mounts a fresh component. + const newGen = (this._portalGenMap.get(entry.id) ?? -1) + 1; + this._portalGenMap.set(entry.id, newGen); + entry.gen = newGen; this._portalEntries.push(entry); } + // Always record the latest container so renderViewModel can distinguish new-DOM vs same-DOM on subsequent calls + this._portalContainerMap.set(entry.id, entry.container); this._portalHostSetter?.([...this._portalEntries]); } diff --git a/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts b/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts index af6114afa2..cc14699180 100644 --- a/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts +++ b/frameworks-plugins/vue-row-detail-plugin/src/RowDetailTeleportHost.ts @@ -1,4 +1,4 @@ -import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, Teleport } from 'vue'; +import { createVNode, defineComponent, getCurrentInstance, h, nextTick, onBeforeUnmount, onMounted, render, shallowRef } from 'vue'; import type { PropType } from 'vue'; import type { TeleportEntry, VueRowDetailView } from './vueRowDetailView.js'; @@ -34,21 +34,92 @@ export const RowDetailTeleportHost = defineComponent({ }, }, setup(props) { + const hostInstance = getCurrentInstance ? getCurrentInstance() : undefined; const entries = shallowRef([]); + // Map entry id -> { comp: ComponentType, presets: boolean } + const _hostMountCompMap = new Map(); onMounted(() => { props.plugin.registerTeleportHost((newEntries) => { + // Evict cached HostMount components for entries that were removed + const incomingIds = new Set(newEntries.map((e) => String(e.id))); + Array.from(_hostMountCompMap.keys()).forEach((cachedId) => { + if (!incomingIds.has(cachedId)) { + const meta = _hostMountCompMap.get(cachedId); + // Only evict cache when the entry was NOT using inner-grid state presets + if (meta && !meta.presets) { + _hostMountCompMap.delete(cachedId); + } + } + }); + entries.value = [...newEntries]; }); }); onBeforeUnmount(() => { props.plugin.registerTeleportHost(undefined); + // Clear any cached host mount components on unmount + _hostMountCompMap.clear(); }); - return () => - entries.value.map((entry) => - h(Teleport as any, { to: entry.selector, key: String(entry.id) }, () => h(entry.component as any, entry.data)) - ); + return () => { + return entries.value.map((entry) => { + const key = `${String(entry.id)}-${entry.gen ?? 0}`; + + // HostMount: programmatically render the provided component into the target container + const HostMount = (entry: any) => { + return defineComponent({ + name: `RowDetailHostMount_${String(entry.id)}`, + setup() { + let vnode: any = null; + const mount = () => { + const target = document.querySelector(entry.selector) as Element | null; + if (!target) { + return; + } + vnode = createVNode(entry.component as any, { ...entry.data }); + if (hostInstance && vnode) vnode.appContext = hostInstance.appContext; + render(vnode, target); + }; + + onMounted(() => { + nextTick(mount); + }); + + onBeforeUnmount(() => { + const target = document.querySelector(entry.selector) as Element | null; + if (target) { + render(null, target); + } + }); + + return () => null; + }, + }); + }; + + // Cache HostMount component types per entry generation key so vnode type remains stable + const idKey = String(entry.id); + // Determine whether this entry wants inner-grid state presets + const wantsPresets = !!(entry?.data && (entry.data as any).model && (entry.data as any).model.isUsingInnerGridStatePresets); + + // If cache exists for this id, reuse it; otherwise create and store metadata + let Comp = _hostMountCompMap.get(idKey)?.comp; + if (!Comp) { + Comp = HostMount(entry); + _hostMountCompMap.set(idKey, { comp: Comp, presets: wantsPresets }); + } else { + // Update presets flag if changed while still cached + const meta = _hostMountCompMap.get(idKey); + if (meta && meta.presets !== wantsPresets) { + meta.presets = wantsPresets; + _hostMountCompMap.set(idKey, meta); + } + } + + return h(Comp as any, { key }); + }); + }; }, }); diff --git a/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts b/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts index f3dbbb05c9..5de5835e57 100644 --- a/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts +++ b/frameworks-plugins/vue-row-detail-plugin/src/vueRowDetailView.ts @@ -26,6 +26,7 @@ export interface TeleportEntry { selector: string; component: any; data: ViewModelBindableInputData; + gen?: number; } export interface CreatedView { @@ -48,6 +49,10 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { protected _timer?: any; protected _teleportEntries: TeleportEntry[] = []; protected _teleportHostSetter?: (entries: TeleportEntry[]) => void; + /** Monotonically-increasing generation counter per entry id — never resets on removal so key always changes on re-add */ + protected _teleportGenMap: Map = new Map(); + /** Tracks the last container Element handed to Vue Teleport per entry id — survives removal so we can detect new DOM nodes vs same-DOM re-renders */ + protected _teleportContainerMap: Map = new Map(); constructor(private readonly eventPubSubService: EventPubSubService) { super(eventPubSubService); @@ -97,6 +102,7 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { this._teleportEntries = []; this._teleportHostSetter([]); this._views = []; + this._teleportContainerMap.clear(); return; } do { @@ -315,6 +321,13 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { if (this._teleportHostSetter) { // Teleport mode: render preload via RowDetailTeleportHost (keeps Vue context/providers) + // Clear stale HTML when the container is a new DOM node (e.g. loadOnce restored saved HTML via innerHTML). + // Do NOT clear if it's the same DOM node Vue is already managing — that causes unmount errors. + const prevPreloadContainer = this._teleportContainerMap.get('__preload__'); + if (!prevPreloadContainer || containerElement !== prevPreloadContainer) { + containerElement.textContent = ''; + } + this._teleportContainerMap.set('__preload__', containerElement); this._updateTeleportEntry({ id: '__preload__', selector: `.${PRELOAD_CONTAINER_PREFIX}`, @@ -353,6 +366,11 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { // Teleport mode: render via RowDetailTeleportHost so this panel stays inside the Vue component tree, // giving it full access to provide/inject, Pinia, Vue Router, and other providers. const selector = `.${ROW_DETAIL_CONTAINER_PREFIX}${item[this.datasetIdPropName]}`; + const prevContainer = this._teleportContainerMap.get(item[this.datasetIdPropName]); + if (!prevContainer || containerElement !== prevContainer) { + containerElement.textContent = ''; + } + this._teleportContainerMap.set(item[this.datasetIdPropName], containerElement); this._updateTeleportEntry({ id: item[this.datasetIdPropName], selector, @@ -425,20 +443,38 @@ export class VueRowDetailView extends UniversalSlickRowDetailView { /** Add or update a teleport entry and notify the host setter */ protected _updateTeleportEntry(entry: TeleportEntry): void { + console.log('vueRowDetailView._updateTeleportEntry called for id:', entry.id, 'selector:', entry.selector); const idx = this._teleportEntries.findIndex((e) => e.id === entry.id); if (idx >= 0) { + // Entry already exists in the live list — preserve its generation so Vue keeps the same key + // and only patches props (no remount, no loss of inner component state like filter inputs). + entry.gen = this._teleportEntries[idx].gen; this._teleportEntries[idx] = entry; } else { + // Entry is new or was previously removed. Bump the per-id generation counter so the host + // key changes (e.g. "1-0" → "1-1"), ensuring Vue unmounts the old and mounts a fresh component. + const newGen = (this._teleportGenMap.get(entry.id) ?? -1) + 1; + this._teleportGenMap.set(entry.id, newGen); + entry.gen = newGen; this._teleportEntries.push(entry); } + console.log( + 'vueRowDetailView._teleportEntries now:', + this._teleportEntries.map((e) => ({ id: e.id, selector: e.selector, gen: e.gen })) + ); this._teleportHostSetter?.([...this._teleportEntries]); } /** Remove a teleport entry by id and notify the host setter */ protected _removeTeleportEntry(id: string | number): void { + console.log('vueRowDetailView._removeTeleportEntry called for id:', id); const idx = this._teleportEntries.findIndex((e) => e.id === id); if (idx >= 0) { this._teleportEntries.splice(idx, 1); + console.log( + 'vueRowDetailView._teleportEntries after remove:', + this._teleportEntries.map((e) => ({ id: e.id, selector: e.selector, gen: e.gen })) + ); this._teleportHostSetter?.([...this._teleportEntries]); } } diff --git a/frameworks/slickgrid-react/docs/grid-functionalities/row-detail.md b/frameworks/slickgrid-react/docs/grid-functionalities/row-detail.md index c2592ce639..dc9fc6ec4d 100644 --- a/frameworks/slickgrid-react/docs/grid-functionalities/row-detail.md +++ b/frameworks/slickgrid-react/docs/grid-functionalities/row-detail.md @@ -28,6 +28,23 @@ There is currently a known problem with Row Detail when loading the Row Detail C > You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before. Instead, call root.render() on the existing root instead if you want to update it. +##### Portal Host (optional) +To keep Row Detail components inside your React component tree (so `useContext`, Redux, Zustand, and other providers work), add the optional `RowDetailPortalHost` alongside your grid. When present the plugin renders Row Details via React portals; when absent the plugin falls back to the legacy per-row `createRoot()` behavior. + +Example (place next to your grid component): +```tsx +import { ReactRowDetailView, RowDetailPortalHost } from '@slickgrid-universal/react-row-detail-plugin'; + +const rowDetailPlugin = new ReactRowDetailView(eventPubService); + +return ( + <> + + + +); +``` + ## Usage > Starting from version 10, Row Detail is now an optional package and must be installed separately (`@slickgrid-universal/react-row-detail-plugin`) diff --git a/frameworks/slickgrid-vue/docs/grid-functionalities/row-detail.md b/frameworks/slickgrid-vue/docs/grid-functionalities/row-detail.md index aa5a46975c..9a934b438f 100644 --- a/frameworks/slickgrid-vue/docs/grid-functionalities/row-detail.md +++ b/frameworks/slickgrid-vue/docs/grid-functionalities/row-detail.md @@ -103,6 +103,23 @@ function vueGridReady(vueGrid: SlickgridVueInstance) { ``` +### Optional: Host component (Teleport) + +To keep Row Detail components inside your Vue app's component tree (so `provide`/`inject`, Pinia, router, and other context are preserved), mount the `RowDetailTeleportHost` once alongside your grid and pass the Row Detail plugin instance to it. + +```vue + + + +``` + + ### Changing Addon Options Dynamically Row Detail is an addon (commonly known as a plugin and are opt-in addon), because this is not built-in SlickGrid and instead are opt-in, we need to get the instance of that addon object. Once we have the instance, we can use `getOptions` and `setOptions` to get/set any of the addon options, adding `rowDetail` with intellisense should give you this info. diff --git a/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts b/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts index 802f3b1d34..0ee79b9001 100644 --- a/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts +++ b/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts @@ -174,6 +174,34 @@ describe('SlickRowDetailView plugin', () => { expect(collapseAllSpy).toHaveBeenCalled(); }); + it('should call "collapseDetailView" and NOT collapse the row when "onBeforeRowDetailToggle" returns false', () => { + const itemMock = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: false, __sizePadding: 2, __height: 60 }; + vi.spyOn(dataviewStub, 'getItemById').mockReturnValue(itemMock); + const onBeforeSlickEventData = { getReturnValue: () => false } as unknown as SlickEventData; + const beforeToggleSpy = vi.spyOn(plugin.onBeforeRowDetailToggle, 'notify').mockReturnValue(onBeforeSlickEventData); + const updateItemSpy = vi.spyOn(dataviewStub, 'updateItem'); + + plugin.init(gridStub); + plugin.collapseDetailView(itemMock.id); + + expect(beforeToggleSpy).toHaveBeenCalledWith({ grid: gridStub, item: itemMock }, null, plugin); + // updateItem should NOT have been called because the toggle was cancelled + expect(updateItemSpy).not.toHaveBeenCalled(); + }); + + it('should call "collapseDetailView" and collapse the row when "onBeforeRowDetailToggle" returns true', () => { + const itemMock = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: false, __sizePadding: 0, __height: 60 }; + vi.spyOn(dataviewStub, 'getItemById').mockReturnValue(itemMock); + const beforeToggleSpy = vi.spyOn(plugin.onBeforeRowDetailToggle, 'notify'); + const updateItemSpy = vi.spyOn(dataviewStub, 'updateItem'); + + plugin.init(gridStub); + plugin.collapseDetailView(itemMock.id); + + expect(beforeToggleSpy).toHaveBeenCalledWith({ grid: gridStub, item: itemMock }, null, plugin); + expect(updateItemSpy).toHaveBeenCalledWith(itemMock.id, expect.objectContaining({ __collapsed: true })); + }); + it('should update grid row count and re-render grid when "onRowCountChanged" event is triggered', () => { const updateRowCountSpy = vi.spyOn(gridStub, 'updateRowCount'); const renderSpy = vi.spyOn(gridStub, 'render'); diff --git a/packages/row-detail-view-plugin/src/slickRowDetailView.ts b/packages/row-detail-view-plugin/src/slickRowDetailView.ts index 4edafa50e0..5e378edbb6 100644 --- a/packages/row-detail-view-plugin/src/slickRowDetailView.ts +++ b/packages/row-detail-view-plugin/src/slickRowDetailView.ts @@ -287,6 +287,10 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV collapseDetailView(itemId: number | string, isMultipleCollapsing = false): void { const item = this.dataView.getItemById(itemId); if (item) { + // notify before toggling so extensions can prepare (and possibly cancel) + if (this.onBeforeRowDetailToggle.notify({ grid: this._grid, item }, null, this).getReturnValue() === false) { + return; + } if (!isMultipleCollapsing) { this.dataView.beginUpdate(); }