Repository navigation
feat: [SDK-5120] inline notification icons from the service worker so CDN issues preserve the brand icon #1531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0665754
11ad3a1
18124d1
3f8dc7e
707aeeb
22ecec4
3593213
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // In-memory stand-in for the Cache API, which jsdom does not provide. | ||
| // Entries keep insertion order, and `put` on an existing key moves it to the end. | ||
| export class MockCache { | ||
| entries = new Map<string, Response>(); | ||
|
|
||
| match(key: string): Promise<Response | undefined> { | ||
| return Promise.resolve(this.entries.get(key)?.clone()); | ||
| } | ||
|
|
||
| put(key: string, response: Response): Promise<void> { | ||
| this.entries.delete(key); | ||
| this.entries.set(key, response); | ||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| delete(key: string): Promise<boolean> { | ||
| return Promise.resolve(this.entries.delete(key)); | ||
| } | ||
|
|
||
| keys(): Promise<string[]> { | ||
| return Promise.resolve(Array.from(this.entries.keys())); | ||
| } | ||
| } | ||
|
|
||
| export class MockCacheStorage { | ||
| caches = new Map<string, MockCache>(); | ||
|
|
||
| open(name: string): Promise<MockCache> { | ||
| let cache = this.caches.get(name); | ||
| if (!cache) { | ||
| cache = new MockCache(); | ||
| this.caches.set(name, cache); | ||
| } | ||
| return Promise.resolve(cache); | ||
| } | ||
| } | ||
|
|
||
| export const installMockCacheStorage = (): MockCacheStorage => { | ||
| const storage = new MockCacheStorage(); | ||
| Object.defineProperty(globalThis, 'caches', { | ||
| value: storage, | ||
| configurable: true, | ||
| writable: true, | ||
| }); | ||
| return storage; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import type { ContextInterface } from '../context/types'; | |
| import { db, getIdsValue, isReadwriteWedged } from '../database/client'; | ||
| import { getSubscription, setSubscription } from '../database/subscription'; | ||
| import type { OptionKey } from '../database/types'; | ||
| import { FeatureFlag, isFeatureEnabled } from '../features/featureFlags'; | ||
| import Log from '../libraries/Log'; | ||
| import { CustomLinkManager } from '../managers/CustomLinkManager'; | ||
| import { SubscriptionStrategyKind } from '../models/SubscriptionStrategyKind'; | ||
|
|
@@ -368,6 +369,13 @@ export async function initSaveState(overridingPageTitle?: string) { | |
| overridingPageTitle || config.siteName || document.title || 'Notification'; | ||
| await db.put('Options', { key: 'pageTitle', value: pageTitle }); | ||
| Log._info(`Set pageTitle to be '${pageTitle}'.`); | ||
| // The worker also uses this key to fill a missing icon, so it is only set | ||
| // while the inline images flag is on. | ||
| const inlineImages = isFeatureEnabled(FeatureFlag._InlineNotificationImages); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: |
||
| await db.put('Options', { | ||
| key: 'defaultIcon', | ||
| value: inlineImages ? (config.defaultIconUrl ?? null) : null, | ||
| }); | ||
| } | ||
|
|
||
| async function handleAutoResubscribe(isOptedOut: boolean) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| import { installMockCacheStorage, type MockCache } from '__test__/support/mocks/MockCacheStorage'; | ||
| import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test'; | ||
|
|
||
| import { inlineImage } from './inlineImage'; | ||
|
|
||
| const ICON_URL = 'https://img.example.com/icon.png'; | ||
| const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); | ||
| const PNG_DATA_URL = `data:image/png;base64,${btoa('\x89PNG')}`; | ||
|
|
||
| const imageResponse = (bytes: Uint8Array<ArrayBuffer> = PNG_BYTES, type = 'image/png') => | ||
| new Response(bytes, { status: 200, headers: { 'content-type': type } }); | ||
|
|
||
| const fetchMock = vi.fn<typeof fetch>(); | ||
|
|
||
| const openCache = async (): Promise<MockCache> => | ||
| (await caches.open('onesignal-notification-images')) as unknown as MockCache; | ||
|
|
||
| describe('inlineImage', () => { | ||
| beforeEach(() => { | ||
| installMockCacheStorage(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
| fetchMock.mockReset(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| test('returns an empty or data URL unchanged without a fetch', async () => { | ||
| expect(await inlineImage(undefined)).toBeUndefined(); | ||
| expect(await inlineImage('')).toBe(''); | ||
| expect(await inlineImage(PNG_DATA_URL)).toBe(PNG_DATA_URL); | ||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('returns the fetched bytes as a data URL and caches them', async () => { | ||
| fetchMock.mockResolvedValue(imageResponse()); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledWith(ICON_URL, { signal: expect.any(AbortSignal) }); | ||
| const cached = await (await openCache()).match(ICON_URL); | ||
| expect(new Uint8Array(await cached!.arrayBuffer())).toEqual(PNG_BYTES); | ||
| expect(cached!.headers.get('content-type')).toBe('image/png'); | ||
| }); | ||
|
|
||
| test('drops content-type parameters from the data URL', async () => { | ||
| fetchMock.mockResolvedValue(imageResponse(PNG_BYTES, 'image/webp; charset=binary')); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(`data:image/webp;base64,${btoa('\x89PNG')}`); | ||
| }); | ||
|
|
||
| test('accepts an upper-case content-type', async () => { | ||
| fetchMock.mockResolvedValue(imageResponse(PNG_BYTES, 'IMAGE/PNG')); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
| }); | ||
|
|
||
| test('returns the remote URL when the response has no content-type and caches nothing', async () => { | ||
| fetchMock.mockResolvedValue(new Response(PNG_BYTES, { status: 200 })); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(ICON_URL); | ||
| expect((await openCache()).entries.size).toBe(0); | ||
| }); | ||
|
|
||
| test('returns the remote URL for a non-image content type and caches nothing', async () => { | ||
| const cancel = vi.fn(); | ||
| fetchMock.mockResolvedValue( | ||
| new Response(new ReadableStream({ cancel }), { | ||
| status: 200, | ||
| headers: { 'content-type': 'text/html; charset=utf-8' }, | ||
| }), | ||
| ); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(ICON_URL); | ||
| expect((await openCache()).entries.size).toBe(0); | ||
| expect(cancel).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('uses the cached copy when the response is not an image', async () => { | ||
| fetchMock.mockResolvedValueOnce(imageResponse()); | ||
| await inlineImage(ICON_URL); | ||
|
|
||
| fetchMock.mockResolvedValueOnce(imageResponse(PNG_BYTES, 'text/html')); | ||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
| }); | ||
|
|
||
| test('uses the cached copy when the fetch rejects', async () => { | ||
| fetchMock.mockResolvedValueOnce(imageResponse()); | ||
| await inlineImage(ICON_URL); | ||
|
|
||
| fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); | ||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
| }); | ||
|
|
||
| test('uses the cached copy when the response is not ok', async () => { | ||
| fetchMock.mockResolvedValueOnce(imageResponse()); | ||
| await inlineImage(ICON_URL); | ||
|
|
||
| fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })); | ||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
| }); | ||
|
|
||
| test('returns the remote URL when the fetch fails and nothing is cached', async () => { | ||
| fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(ICON_URL); | ||
| expect((await openCache()).entries.size).toBe(0); | ||
| }); | ||
|
|
||
| test('returns the remote URL for an image over the inline limit and does not cache it', async () => { | ||
| fetchMock.mockResolvedValue(imageResponse(new Uint8Array(128 * 1024 + 1))); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(ICON_URL); | ||
| expect((await openCache()).entries.size).toBe(0); | ||
| }); | ||
|
|
||
| test('skips the body when Content-Length is over the inline limit', async () => { | ||
| const cancel = vi.fn(); | ||
| fetchMock.mockResolvedValue( | ||
| new Response(new ReadableStream({ cancel }), { | ||
| status: 200, | ||
| headers: { 'content-type': 'image/png', 'content-length': String(128 * 1024 + 1) }, | ||
| }), | ||
| ); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(ICON_URL); | ||
| expect(cancel).toHaveBeenCalled(); | ||
| expect((await openCache()).entries.size).toBe(0); | ||
| }); | ||
|
|
||
| test('stops reading a stream once it passes the inline limit', async () => { | ||
| const chunk = new Uint8Array(64 * 1024); | ||
| let pulls = 0; | ||
| const cancel = vi.fn(); | ||
| fetchMock.mockResolvedValue( | ||
| new Response( | ||
| new ReadableStream<Uint8Array<ArrayBuffer>>({ | ||
| pull(controller) { | ||
| pulls++; | ||
| controller.enqueue(chunk); | ||
| }, | ||
| cancel, | ||
| }), | ||
| { status: 200, headers: { 'content-type': 'image/png' } }, | ||
| ), | ||
| ); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(ICON_URL); | ||
| expect(cancel).toHaveBeenCalled(); | ||
| expect(pulls).toBeLessThan(6); | ||
| expect((await openCache()).entries.size).toBe(0); | ||
| }); | ||
|
|
||
| test('returns the fetched bytes when Cache Storage cannot be opened', async () => { | ||
| vi.stubGlobal('caches', { open: () => Promise.reject(new Error('quota')) }); | ||
| fetchMock.mockResolvedValue(imageResponse()); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
| }); | ||
|
|
||
| test('returns the fetched bytes when the cache write throws', async () => { | ||
| const cache = await openCache(); | ||
| vi.spyOn(cache, 'put').mockRejectedValue(new DOMException('quota', 'QuotaExceededError')); | ||
| fetchMock.mockResolvedValue(imageResponse()); | ||
|
|
||
| expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL); | ||
| expect(cache.entries.size).toBe(0); | ||
| }); | ||
|
|
||
| test('keeps only the 10 most recent images in the cache', async () => { | ||
| fetchMock.mockImplementation(() => Promise.resolve(imageResponse())); | ||
| for (let i = 0; i < 12; i++) { | ||
| await inlineImage(`${ICON_URL}?v=${i}`); | ||
| } | ||
|
|
||
| const keys = await (await openCache()).keys(); | ||
| expect(keys).toHaveLength(10); | ||
| expect(keys[0]).toBe(`${ICON_URL}?v=2`); | ||
| expect(keys[9]).toBe(`${ICON_URL}?v=11`); | ||
| }); | ||
|
|
||
| test('encodes an image larger than one base64 chunk', async () => { | ||
| const bytes = new Uint8Array(0x8000 + 3).map((_, i) => i % 256); | ||
| fetchMock.mockResolvedValue(imageResponse(bytes)); | ||
|
|
||
| const dataUrl = await inlineImage(ICON_URL); | ||
|
|
||
| const base64 = dataUrl!.slice('data:image/png;base64,'.length); | ||
| expect(Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))).toEqual(bytes); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isFeatureEnabledreads the previous localStorage list here, whilerefreshFeatureFlagsis fire-and-forget fromOneSignal.initandget()retries 10s/20s/30s. On the load that turnssdk_web_inline_notification_imageson, this write typically storesdefaultIcon: nulland nothing updates it after the features response lands. The worker then cannot fill a missing icon or treat the Site Setup icon as default whenimageis present. The off-transition is the reverse:defaultIconstays set after the worker mirror is cleared.Write
defaultIconfrom the flag listrefreshFeatureFlagsactually stored, or await that refresh (including its failure path) before this put.