Push receiver: read the declarative notification from the push event (Safari history fix) (#179)

* Push receiver: read the declarative notification from the push event (Safari history fix)

On an iPhone the pushes arrived with the right content, but every history entry was the generic
placeholder: with "mutable": true Safari (iOS >= 18.4) dispatches the regular push event with the
proposed Notification in event.notification and event.data null, and the worker read only
event.data. The push handler now takes event.notification first and shows the same content
itself (one notification, entry written at arrival); a tap on a notification the platform
displayed without the worker rebuilds the entry from the Notification's data, and the click
path's history write is bounded like the push path's. Tests pin both paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bump pointer: internal (push-relay: Safari declarative-push finding)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 14:59:44 +02:00
committed by GitHub
parent be0e37008a
commit d378fabc01
3 changed files with 127 additions and 21 deletions
@@ -11,9 +11,16 @@ import { GENERIC_BODY, GENERIC_TITLE } from "../push/pushCore";
type Handler = (event: unknown) => void;
interface FakeClients {
claim: ReturnType<typeof vi.fn>;
matchAll: ReturnType<typeof vi.fn>;
openWindow: ReturnType<typeof vi.fn>;
}
interface FakeSelf {
handlers: Map<string, Handler>;
showNotification: ReturnType<typeof vi.fn>;
clients: FakeClients;
/** Order of the side effects the contract cares about. */
sequence: string[];
}
@@ -24,6 +31,11 @@ function fakeSelf(): FakeSelf {
const showNotification = vi.fn(async () => {
sequence.push("showNotification");
});
const clients: FakeClients = {
claim: vi.fn(async () => undefined),
matchAll: vi.fn(async () => []),
openWindow: vi.fn(async () => null),
};
const self = {
addEventListener: (type: string, handler: Handler) => {
handlers.set(type, handler);
@@ -33,15 +45,11 @@ function fakeSelf(): FakeSelf {
showNotification,
pushManager: { getSubscription: vi.fn(async () => null) },
},
clients: {
claim: vi.fn(async () => undefined),
matchAll: vi.fn(async () => []),
openWindow: vi.fn(async () => null),
},
clients,
location: { origin: "https://portal.example.test" },
};
vi.stubGlobal("self", self);
return { handlers, showNotification, sequence };
return { handlers, showNotification, clients, sequence };
}
/** An IDBFactory whose open() request never fires any handler. */
@@ -210,4 +218,87 @@ describe("push service worker glue (K25)", () => {
await expect(event.waited).resolves.toBeDefined();
});
});
describe("push event carrying a declarative notification (Push API mutable, Safari ≥ 18.4)", () => {
const declared = {
title: "Enrollment finished",
body: "DESKTOP-4711 (…CDE9) finished enrollment.",
tag: "session-x",
data: { id: "srv-1", type: "session_watch", ts: "2026-10-08T12:40:15Z", severity: "success", facts: [], portalUrl: "https://portal.example.test/sessions?id=x", scope: "platform" },
};
it("reads event.notification when event.data is null, shows the same content and persists it", async () => {
const env = await loadWorker(hangingIndexedDb);
const event = fakeEvent({ data: null, notification: declared });
env.handlers.get("push")!(event);
expect(env.showNotification).toHaveBeenCalledTimes(1);
expect(env.showNotification.mock.calls[0][0]).toBe("Enrollment finished");
expect(env.showNotification.mock.calls[0][1]).toMatchObject({
body: "DESKTOP-4711 (…CDE9) finished enrollment.",
tag: "session-x",
data: { id: "srv-1", entry: expect.objectContaining({ id: "srv-1", type: "session_watch", severity: "success", portalUrl: "https://portal.example.test/sessions?id=x", scope: "platform" }) },
});
expect(env.sequence).toEqual(["showNotification", "indexedDB.open"]);
});
it("prefers event.notification over an unreadable event.data", async () => {
const env = await loadWorker(hangingIndexedDb);
const event = fakeEvent({ data: UNREADABLE_DATA, notification: declared });
env.handlers.get("push")!(event);
expect(env.showNotification.mock.calls[0][0]).toBe("Enrollment finished");
});
});
describe("notificationclick on a notification the platform displayed itself", () => {
function clickEvent(notification: Record<string, unknown>) {
const event = fakeEvent({}) as FakeEvent & { notification: Record<string, unknown> };
event.notification = { close: vi.fn(), ...notification };
return event;
}
const platformShown = {
title: "Enrollment failed",
body: "DESKTOP-4711 (…CDE9) failed enrollment.",
tag: "session-y",
data: { id: "srv-2", type: "session_watch", ts: "2026-10-08T12:45:00Z", severity: "error", facts: [{ name: "Device", value: "DESKTOP-4711" }], portalUrl: null, scope: "tenant" },
};
it("rebuilds the history entry from the notification's data, writes it and opens the entry", async () => {
const env = await loadWorker(erroringIndexedDb);
const event = clickEvent(platformShown);
env.handlers.get("notificationclick")!(event);
await expect(event.waited).resolves.toBeUndefined();
expect(env.indexedDbOpen).toHaveBeenCalledTimes(1);
expect(env.clients.openWindow).toHaveBeenCalledTimes(1);
expect(String(env.clients.openWindow.mock.calls[0][0])).toContain("srv-2");
});
it("opens the page even when the history write never answers (bounded like the push path)", async () => {
const env = await loadWorker(hangingIndexedDb);
const event = clickEvent(platformShown);
env.handlers.get("notificationclick")!(event);
await vi.advanceTimersByTimeAsync(5000);
await expect(event.waited).resolves.toBeUndefined();
expect(env.clients.openWindow).toHaveBeenCalledTimes(1);
});
it("keeps using the entry the push handler attached when there is one", async () => {
const env = await loadWorker(erroringIndexedDb);
const attached = { id: "srv-3", ts: "2026-10-08T12:50:00Z", title: "t", body: "b", type: "push_test", severity: "info", facts: [], portalUrl: null, navigate: null, tag: null, scope: "platform", generic: false };
const event = clickEvent({ title: "t", body: "b", data: { id: "srv-3", entry: attached } });
env.handlers.get("notificationclick")!(event);
await expect(event.waited).resolves.toBeUndefined();
expect(String(env.clients.openWindow.mock.calls[0][0])).toContain("srv-3");
});
});
});
+29 -14
View File
@@ -133,33 +133,48 @@ function handlePush(event, raw) {
event.waitUntil(Promise.all([show(entry), persist(entry)]));
}
// Declarative Web Push with "mutable": true (Push API draft; Safari / iOS ≥ 18.4, verified on a
// device 2026-10-08): the regular push event carries the proposed Notification in
// event.notification and event.data is null. The worker shows the same content itself, so the
// proposed notification is dropped and exactly one appears, and the history gets its entry at
// arrival instead of only after a tap. Every other browser hands the raw JSON as event.data.
self.addEventListener("push", (event) => {
handlePush(event, safeParse(event.data));
const declared = event.notification ?? null;
handlePush(event, declared ?? safeParse(event.data));
});
// Safari / iOS ≥ 18.4: Declarative Web Push with "mutable": true wakes the worker with the
// proposed notification instead of a payload (event name verified at interop, K25).
// The earlier WebKit shape of the same mechanism: a separate pushnotification event with the
// proposed notification (kept until no supported Safari fires it).
self.addEventListener("pushnotification", (event) => {
const proposed = event.notification ?? event.proposedNotification ?? null;
handlePush(event, proposed ?? safeParse(event.data ?? null));
});
/**
* The history entry behind a clicked notification: the one the push handler attached, or —
* for a notification the platform displayed straight from the declarative JSON, without the
* worker running — rebuilt from the Notification itself (its data is the payload's data member).
* @param {Notification} notification
* @returns {import("./sw-core.js").HistoryEntry | null}
*/
function entryFromClicked(notification) {
const data = notification.data ?? {};
if (data.entry && typeof data.entry === "object") return data.entry;
if (typeof data.id !== "string") return null;
return normalizePayload({ title: notification.title, body: notification.body, tag: notification.tag, data }, Date.now());
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const data = event.notification.data ?? {};
const id = typeof data.id === "string" ? data.id : null;
const entry = data.entry && typeof data.entry === "object" ? data.entry : null;
const entry = entryFromClicked(event.notification);
const id = entry ? entry.id : null;
const target = new URL("/push/" + (id ? entryFragment(id) : ""), self.location.origin).toString();
event.waitUntil(
(async () => {
// The push handler may have lost the race against IndexedDB; the click writes the entry if missing.
if (entry) {
try {
await writeHistoryEntry(entry);
} catch {
// Display already happened; the history is best effort.
}
}
// The push handler may have lost the race against IndexedDB, or never ran: the click writes
// the entry if missing — bounded like every history write, so a stuck database cannot keep
// the tap from opening the page.
if (entry) await persist(entry);
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
if (!new URL(client.url).pathname.startsWith("/push")) continue;