diff --git a/src/components/icon/icon-state.broadcast.ts b/src/components/icon/icon-state.broadcast.ts index abca40130..62b684866 100644 --- a/src/components/icon/icon-state.broadcast.ts +++ b/src/components/icon/icon-state.broadcast.ts @@ -84,8 +84,12 @@ export class IconsStateBroadcast { this.send({ actionType: ActionType.SyncState, - collections: this._getUserSetCollection(this._iconsCollection), - references: this._getUserRefsCollection(this._iconReferences), + collections: this._getUserSetCollection( + this._iconsCollection + ).toPlainMap(), + references: this._getUserRefsCollection( + this._iconReferences + ).toPlainMap(), origin: IconsStateBroadcast._origin, }); } diff --git a/src/components/icon/icon.registry.ts b/src/components/icon/icon.registry.ts index 879703463..27a324f6f 100644 --- a/src/components/icon/icon.registry.ts +++ b/src/components/icon/icon.registry.ts @@ -85,7 +85,7 @@ class IconsRegistry { this._broadcast.send({ actionType: ActionType.RegisterIcon, - collections: icons, + collections: icons.toPlainMap(), }); this._notifyAll(name, collection); @@ -148,7 +148,7 @@ class IconsRegistry { this._broadcast.send({ actionType: ActionType.UpdateIconReference, - references: refs, + references: refs.toPlainMap(), }); } } diff --git a/src/components/icon/icon.spec.ts b/src/components/icon/icon.spec.ts index f6f6742d7..41252ce04 100644 --- a/src/components/icon/icon.spec.ts +++ b/src/components/icon/icon.spec.ts @@ -470,6 +470,134 @@ describe('Icon BFCache (pageshow/pagehide) handling', () => { }); }); +describe('DefaultMap serialization for cross-browser compatibility', () => { + let channel: BroadcastChannel; + let events: MessageEvent[] = []; + const collectionName = 'serialization-test'; + + const handler = (message: MessageEvent) => + events.push(message); + + beforeEach(async () => { + channel = new BroadcastChannel('ignite-ui-icon-channel'); + channel.addEventListener('message', handler); + events = []; + }); + + afterEach(async () => { + channel.close(); + events = []; + }); + + it('DefaultMap.toPlainMap() returns a plain Map instance, not DefaultMap', () => { + const defaultMap = createIconDefaultMap(); + const plainMap = defaultMap.toPlainMap(); + + // Verify that plainMap is a Map but not a DefaultMap + expect(plainMap).to.be.instanceOf(Map); + expect(plainMap[Symbol.toStringTag]).to.equal('Map'); + expect(defaultMap[Symbol.toStringTag]).to.equal('DefaultMap'); + }); + + it('toPlainMap() preserves all entries from DefaultMap', () => { + const defaultMap = createIconDefaultMap(); + const testData: SvgIcon = { svg: bugSvg }; + + // Add data to DefaultMap + const collection = defaultMap.getOrCreate('test-collection'); + collection.set('bug', testData); + + // Convert to plain Map + const plainMap = defaultMap.toPlainMap(); + + // Verify entries are preserved + expect(plainMap.has('test-collection')).to.be.true; + const plainCollection = plainMap.get('test-collection'); + expect(plainCollection?.get('bug')).to.eql(testData); + }); + + it('broadcasted collections data is sent as plain Maps', async () => { + registerIconFromText('test-icon', bugSvg, collectionName); + await aTimeout(0); + + const { collections } = first(events).data; + + expect(collections?.[Symbol.toStringTag]).to.equal('Map'); + // Verify the inner collection is also a Map + const innerCollection = collections?.get(collectionName); + expect(innerCollection?.[Symbol.toStringTag]).to.equal('Map'); + expect(innerCollection?.get('test-icon')).to.eql( + getIconRegistry().get('test-icon', collectionName) + ); + }); + + it('broadcasted references data is sent as plain Maps', async () => { + registerIconFromText('ref-test', bugSvg, collectionName); + const refName = 'bug-ref'; + const refCollection = 'ref-collection'; + + setIconRef(refName, refCollection, { + name: 'ref-test', + collection: collectionName, + }); + await aTimeout(0); + + const { references } = last(events).data; + + expect(references?.[Symbol.toStringTag]).to.equal('Map'); + // Verify the inner reference collection is also a Map + const refInnerCollection = references?.get(refCollection); + expect(refInnerCollection?.[Symbol.toStringTag]).to.equal('Map'); + expect(refInnerCollection?.get(refName)).to.eql( + getIconRegistry().getIconRef(refName, refCollection) + ); + }); + + it('nested plain Maps can be structured cloned and transmitted safely', async () => { + registerIconFromText('nested-icon', bugSvg, collectionName); + await aTimeout(0); + + const { collections } = first(events).data; + + // Verify that nested plain Maps can be structured-cloned (as BroadcastChannel would do) + const clonedCollections = structuredClone(collections); + + // Outer structure should remain a Map + expect(clonedCollections?.[Symbol.toStringTag]).to.equal('Map'); + + // Inner collection for the test collection should also remain a Map + const clonedInnerCollection = clonedCollections?.get(collectionName); + expect(clonedInnerCollection?.[Symbol.toStringTag]).to.equal('Map'); + + // The nested icon entry should be preserved after cloning + expect(clonedInnerCollection?.has('nested-icon')).to.be.true; + }); + + it('toPlainMap preserves nested Map structures for icon collections', () => { + const defaultMap = createIconDefaultMap(); + + // Create nested DefaultMap with icons + const collection1 = defaultMap.getOrCreate('material'); + collection1.set('home', { svg: bugSvg }); + collection1.set('settings', { svg: virusSvg }); + + const collection2 = defaultMap.getOrCreate('bootstrap'); + collection2.set('star', { svg: searchSvg }); + + // Convert to plain Map + const plainMap = defaultMap.toPlainMap(); + + // Verify structure is preserved + expect(plainMap.size).to.equal(2); + expect(plainMap.get('material')!.size).to.equal(2); + expect(plainMap.get('bootstrap')!.size).to.equal(1); + + // Verify nested data integrity + expect(plainMap.get('material')?.get('home')?.svg).to.include(' { before(() => { defineComponents(IgcIconComponent); diff --git a/src/components/icon/registry/default-map.ts b/src/components/icon/registry/default-map.ts index 120ebfacf..a077f43b5 100644 --- a/src/components/icon/registry/default-map.ts +++ b/src/components/icon/registry/default-map.ts @@ -55,6 +55,29 @@ class DefaultMap extends Map { return this.get(key) as V; } + + /** + * Converts the DefaultMap to a plain Map for structured cloning. + * + * @remarks + * This method helps with cross-browser compatibility when using BroadcastChannel + * or postMessage, as custom Map subclasses are not properly cloned in Safari. + * Returns a plain Map instance so that the container itself can be structured-cloned + * consistently across browsers. Structured cloning will still only succeed if the + * map's keys and values are themselves structured-cloneable. + * + * @returns A plain Map with the same entries as this DefaultMap. + * + * @example + * ```typescript + * const defaultMap = new DefaultMap>(); + * const plainMap = defaultMap.toPlainMap(); + * channel.postMessage({ data: plainMap }); + * ``` + */ + public toPlainMap(): Map { + return new Map(this.entries()); + } } /**