Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/icon/icon-state.broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Comment thread
rkaraivanov marked this conversation as resolved.
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/icon/icon.registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class IconsRegistry {

this._broadcast.send({
actionType: ActionType.RegisterIcon,
collections: icons,
collections: icons.toPlainMap(),
});

this._notifyAll(name, collection);
Expand Down Expand Up @@ -148,7 +148,7 @@ class IconsRegistry {

this._broadcast.send({
actionType: ActionType.UpdateIconReference,
references: refs,
references: refs.toPlainMap(),
});
}
}
Expand Down
128 changes: 128 additions & 0 deletions src/components/icon/icon.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,134 @@ describe('Icon BFCache (pageshow/pagehide) handling', () => {
});
});

describe('DefaultMap serialization for cross-browser compatibility', () => {
let channel: BroadcastChannel;
let events: MessageEvent<BroadcastIconsChangeMessage>[] = [];
const collectionName = 'serialization-test';

const handler = (message: MessageEvent<BroadcastIconsChangeMessage>) =>
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<string, SvgIcon>();
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<string, SvgIcon>();
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(
Comment thread
rkaraivanov marked this conversation as resolved.
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(
Comment thread
rkaraivanov marked this conversation as resolved.
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<string, SvgIcon>();

// 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('<svg');
expect(plainMap.get('bootstrap')?.get('star')?.svg).to.include('<svg');
});
});

describe('Icon component', () => {
before(() => {
defineComponents(IgcIconComponent);
Expand Down
23 changes: 23 additions & 0 deletions src/components/icon/registry/default-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ class DefaultMap<K, V> extends Map<K, V> {

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<string, Set<number>>();
Comment thread
rkaraivanov marked this conversation as resolved.
* const plainMap = defaultMap.toPlainMap();
* channel.postMessage({ data: plainMap });
* ```
*/
public toPlainMap(): Map<K, V> {
return new Map(this.entries());
}
}

/**
Expand Down