From 498601c21abcc81c0c7fd947ffb475e227c0e9cd Mon Sep 17 00:00:00 2001 From: Mustafa Zaki Assagaf Date: Sat, 21 Dec 2024 22:55:04 +0700 Subject: [PATCH] feat: add MessageChannel polyfill to support react 19 (#238) --- packages/tuono/src/build/index.ts | 6 + packages/tuono/src/ssr/index.tsx | 3 + .../src/ssr/polyfills/messageChannel.spec.ts | 140 ++++++++++++++++++ .../tuono/src/ssr/polyfills/messageChannel.ts | 96 ++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 packages/tuono/src/ssr/polyfills/messageChannel.spec.ts create mode 100644 packages/tuono/src/ssr/polyfills/messageChannel.ts diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index fd6e13ac..306a7fc2 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -15,6 +15,12 @@ const VITE_SSR_PLUGINS: Array = [ enforce: 'post', ...inject({ ReadableStream: ['web-streams-polyfill', 'ReadableStream'], + + /** + * Added to support `react@19` + * @see https://github.com/tuono-labs/tuono/issues/218 + */ + MessageChannel: ['tuono/ssr', 'MessageChannelPolyfill'], }), }, ] diff --git a/packages/tuono/src/ssr/index.tsx b/packages/tuono/src/ssr/index.tsx index 0c7e63ff..644742f6 100644 --- a/packages/tuono/src/ssr/index.tsx +++ b/packages/tuono/src/ssr/index.tsx @@ -8,6 +8,7 @@ import { HelmetProvider } from 'react-helmet-async' import { RouterProvider, createRouter } from 'tuono-router' import type { createRoute } from 'tuono-router' +import { MessageChannelPolyfill } from './polyfills/messageChannel' import { streamToString } from './utils' type RouteTree = ReturnType @@ -94,3 +95,5 @@ export function serverSideRendering(routeTree: RouteTree) { ` } } + +export { MessageChannelPolyfill } diff --git a/packages/tuono/src/ssr/polyfills/messageChannel.spec.ts b/packages/tuono/src/ssr/polyfills/messageChannel.spec.ts new file mode 100644 index 00000000..cae5c5b9 --- /dev/null +++ b/packages/tuono/src/ssr/polyfills/messageChannel.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' + +import { MessageChannelPolyfill, MessagePortPolyfill } from './messageChannel' + +describe('MessagePortPolyfill', () => { + it('should invoke onmessage when a message is posted', () => { + const port = new MessagePortPolyfill() + let messageReceived: string | null = null + + port.onmessage = (event: MessageEvent): void => { + messageReceived = event.data + } + + port.dispatchEvent({ data: 'Hello, world!' } as MessageEvent) + expect(messageReceived).toBe('Hello, world!') + }) + + it('should handle multiple event listeners', () => { + const port = new MessagePortPolyfill() + const messages: Array = [] + + const listener1 = ((event: MessageEvent): void => { + messages.push('Listener1: ' + event.data) + }) as EventListener + const listener2 = ((event: MessageEvent): void => { + messages.push('Listener2: ' + event.data) + }) as EventListener + + port.addEventListener('message', listener1) + port.addEventListener('message', listener2) + + port.dispatchEvent({ data: 'Test message' } as MessageEvent) + expect(messages).toEqual([ + 'Listener1: Test message', + 'Listener2: Test message', + ]) + }) + + it('should not invoke removed event listeners', () => { + const port = new MessagePortPolyfill() + const messages: Array = [] + + const listener = ((event: MessageEvent): void => { + messages.push(event.data) + }) as EventListener + + port.addEventListener('message', listener) + port.dispatchEvent({ data: 'First message' } as MessageEvent) + + port.removeEventListener('message', listener) + port.dispatchEvent({ data: 'Second message' } as MessageEvent) + + expect(messages).toEqual(['First message']) + }) + + it('should not post messages if otherPort is null', () => { + const port = new MessagePortPolyfill() + let messageReceived: string | null = null + + port.onmessage = (event: MessageEvent): void => { + messageReceived = event.data + } + + port.postMessage('Hello!') + expect(messageReceived).toBeNull() + }) +}) + +describe('MessageChannelPolyfill', () => { + it('should send and receive messages between ports', () => { + const channel = new MessageChannelPolyfill() + const messages: Array = [] + + channel.port1.onmessage = (event: MessageEvent): void => { + messages.push(event.data) + } + + channel.port2.postMessage('Hello, port1!') + channel.port2.postMessage('How are you?') + + expect(messages).toEqual(['Hello, port1!', 'How are you?']) + }) + + it('should support addEventListener and removeEventListener', () => { + const channel = new MessageChannelPolyfill() + const messages: Array = [] + + const listener = ((event: MessageEvent): void => { + messages.push(event.data) + }) as EventListener + + channel.port1.addEventListener('message', listener) + channel.port2.postMessage('Hello, port1!') + expect(messages).toEqual(['Hello, port1!']) + + channel.port1.removeEventListener('message', listener) + channel.port2.postMessage('Message after removing listener') + + expect(messages).toEqual(['Hello, port1!']) + }) + + it('should handle bidirectional communication between ports', () => { + const channel = new MessageChannelPolyfill() + const messagesPort1: Array = [] + const messagesPort2: Array = [] + + channel.port1.onmessage = (event: MessageEvent): void => { + messagesPort1.push(event.data) + } + + channel.port2.onmessage = (event: MessageEvent): void => { + messagesPort2.push(event.data) + } + + channel.port1.postMessage('Hello, port2!') + channel.port2.postMessage('Hello, port1!') + + expect(messagesPort1).toEqual(['Hello, port1!']) + expect(messagesPort2).toEqual(['Hello, port2!']) + }) +}) + +describe('MessagePort', () => { + it('should not send a message on close', () => { + const { port1, port2 } = new MessageChannelPolyfill() + + const messages: Array = [] + port1.onmessage = (event: MessageEvent): void => { + messages.push(event.data) + } + + port2.postMessage('Test message') + expect(messages).toEqual(['Test message']) + + port1.close() + port2.postMessage('Another message') + + expect(messages).toEqual(['Test message']) + }) +}) diff --git a/packages/tuono/src/ssr/polyfills/messageChannel.ts b/packages/tuono/src/ssr/polyfills/messageChannel.ts new file mode 100644 index 00000000..65b614c0 --- /dev/null +++ b/packages/tuono/src/ssr/polyfills/messageChannel.ts @@ -0,0 +1,96 @@ +/* Modified from https://github.com/rocwind/message-port-polyfill/blob/master/src/index.ts + * MIT License + * + * Copyright (c) 2019 Roc + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export class MessagePortPolyfill implements MessagePort { + onmessage: ((this: MessagePort, ev: MessageEvent) => unknown) | null = null + onmessageerror: ((this: MessagePort, ev: MessageEvent) => unknown) | null = + null + + otherPort: MessagePortPolyfill | null = null + private onmessageListeners: Array<(ev: MessageEvent) => void> = [] + private isClosed = false + + dispatchEvent(event: MessageEvent): boolean { + if (this.isClosed) return false + if (this.onmessage) { + this.onmessage(event) + } + this.onmessageListeners.forEach((listener) => listener(event)) + return true + } + + postMessage(message: unknown): void { + if (this.isClosed || !this.otherPort) return + const event = new MessageEvent('message', { data: message }) + this.otherPort.dispatchEvent(event) + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + ): void { + if (this.isClosed || type !== 'message') return + if ( + typeof listener === 'function' && + !this.onmessageListeners.includes(listener) + ) { + this.onmessageListeners.push(listener as (ev: MessageEvent) => void) + } + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + ): void { + if (this.isClosed || type !== 'message') return + if (typeof listener === 'function') { + const index = this.onmessageListeners.indexOf( + listener as (ev: MessageEvent) => void, + ) + if (index !== -1) { + this.onmessageListeners.splice(index, 1) + } + } + } + + start(): void { + // do nothing at this moment + } + + close(): void { + this.isClosed = true + } +} + +export class MessageChannelPolyfill implements MessageChannel { + readonly port1: MessagePortPolyfill + readonly port2: MessagePortPolyfill + + constructor() { + this.port1 = new MessagePortPolyfill() + this.port2 = new MessagePortPolyfill() + this.port1.otherPort = this.port2 + this.port2.otherPort = this.port1 + } +}