Skip to content
Open
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
72 changes: 72 additions & 0 deletions ts/packages/popup/src/port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ async function roundTrip(app: MessagePort, popup: MessagePort): Promise<unknown[
return received
}

/**
* Runs `start`, then every microtask it queues, in order, and returns what each threw: the
* errors the event loop would otherwise report.
*/
function runMicrotasks(start: () => void): unknown[] {
const microtasks: Array<() => void> = []
const thrown: unknown[] = []
vi.stubGlobal('queueMicrotask', (task: () => void) => void microtasks.push(task))
try {
start()
for (let task = microtasks.shift(), runs = 1; task; task = microtasks.shift(), runs++) {
// A microtask that keeps queueing itself would otherwise hang the suite.
if (runs > 100) throw new Error('microtasks never settle')
try {
task()
} catch (error) {
thrown.push(error)
}
}
} finally {
vi.unstubAllGlobals()
}
return thrown
}

describe('MessagePort handshake [POPUP-PORT-001]', () => {
it('authenticates both endpoints and resolves entangled ports after the echo', async () => {
const h = listen()
Expand Down Expand Up @@ -354,6 +379,31 @@ describe('PortCarrier [POPUP-PORT-002]', () => {
carrier.close()
})

it('delivers the rest of its backlog in order when a handler throws [POPUP-API-003] [POPUP-KEEPER-001]', async () => {
const channel = new MessageChannel()
channel.port2.postMessage({ type: 'queued' })
const backlog = [{ type: 'fails' }, { type: 'held' }, { type: 'fails' }]
const carrier = new PortCarrier(channel.port1, APP_ORIGIN, backlog)
const failure = new Error('handler failure')
const received: unknown[] = []
const thrown = runMicrotasks(() =>
carrier.on((value) => {
received.push(value)
if ((value as { type: string }).type === 'fails') throw failure
}),
)
await tick()
expect(received).toEqual([
{ type: 'fails' },
{ type: 'held' },
{ type: 'fails' },
{ type: 'queued' },
])
expect(thrown).toHaveLength(2)
for (const error of thrown) expect(error).toBe(failure)
carrier.close()
})

it('hands the undelivered backlog on when a delivered value makes the document leave [POPUP-CONNECTION-003]', async () => {
const channel = new MessageChannel()
channel.port2.postMessage({ type: 'queued' })
Expand All @@ -371,6 +421,28 @@ describe('PortCarrier [POPUP-PORT-002]', () => {
expect(handedOn).toEqual([{ type: 'second' }, { type: 'third' }, { type: 'queued' }])
})

it('hands the rest on and stops when a handler detaches then throws [POPUP-API-003] [POPUP-CONNECTION-003]', async () => {
const channel = new MessageChannel()
channel.port2.postMessage({ type: 'queued' })
const backlog = [{ type: 'leave' }, { type: 'second' }]
const carrier = new PortCarrier(channel.port1, APP_ORIGIN, backlog)
const failure = new Error('handler failure')
const received: unknown[] = []
let handedOn: unknown[] = []
const thrown = runMicrotasks(() =>
carrier.on((value) => {
received.push(value)
handedOn = carrier.detach().backlog
throw failure
}),
)
await tick()
expect(thrown).toHaveLength(1)
expect(thrown[0]).toBe(failure)
expect(received).toEqual([{ type: 'leave' }])
expect(handedOn).toEqual([{ type: 'second' }, { type: 'queued' }])
})

it('surrenders an unstarted port untouched, with only the backlog it was given', () => {
const channel = new MessageChannel()
const carrier = new PortCarrier(channel.port1, APP_ORIGIN, [{ type: 'held' }])
Expand Down
13 changes: 10 additions & 3 deletions ts/packages/popup/src/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,16 @@ export class PortCarrier implements Carrier {
port.start()
// A microtask still precedes every task that dispatches the port's own values. Values leave
// the backlog one at a time, so a handler that hands the port on hands the rest on with it.
queueMicrotask(() => {
while (this.port === port && this.backlog.length) handler(this.backlog.shift())
})
// A handler's exception is the caller's: the rest drains in a fresh microtask, still ahead
// of the port, while the exception propagates.
const drain = (): void => {
try {
while (this.port === port && this.backlog.length) handler(this.backlog.shift())
} finally {
if (this.port === port && this.backlog.length) queueMicrotask(drain)
}
}
queueMicrotask(drain)
return () => {
if (this.port === port) {
port.onmessage = null
Expand Down
Loading