Skip to content

Commit

Permalink
refactor(ui): implement an event-driven bidirectional communication s…
Browse files Browse the repository at this point in the history
…ystem between for the iframe
  • Loading branch information
itsjavi committed Aug 25, 2023
1 parent 32632fa commit afaec92
Show file tree
Hide file tree
Showing 17 changed files with 464 additions and 107 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@r1stack/coding-style": "^0.4.1",
"@r1stack/coding-style": "^0.4.3",
"@swc/core": "^1.3.78",
"@swc/jest": "^0.2.29",
"@testing-library/jest-dom": "^6.1.2",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.4",
"@types/node": "^20.5.4",
"@types/node": "^20.5.6",
"changelogen": "^0.5.5",
"eslint": "^8.47.0",
"husky": "^8.0.3",
Expand Down
6 changes: 3 additions & 3 deletions packages/storylite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@r1stack/core": "^0.4.1",
"@r1stack/core": "^0.4.3",
"lucide-react": "^0.268.0"
},
"devDependencies": {
"@r1stack/coding-style": "^0.4.1",
"@types/node": "^20.5.4",
"@r1stack/coding-style": "^0.4.3",
"@types/node": "^20.5.6",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"publint": "^0.2.2",
Expand Down
3 changes: 0 additions & 3 deletions packages/storylite/src/app/index.test.tsx

This file was deleted.

15 changes: 0 additions & 15 deletions packages/storylite/src/components/StoryFrame.tsx

This file was deleted.

90 changes: 90 additions & 0 deletions packages/storylite/src/components/canvas/CanvasIframe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { HTMLProps } from 'react'
import { cn } from '@r1stack/core'

import { ElementIds } from '@/types'

import { useStoryLiteConfig } from '../../app/context/StoriesDataContext'
import { useCanvasIframe } from './useCanvasIframe'

const allowList = [
'autoplay',
'camera',
'encrypted-media',
'fullscreen',
'geolocation',
'microphone',
'midi',
'payment',
'picture-in-picture',
'clipboard-write',
'accelerometer',
'gyroscope',
'magnetometer',
'xr-spatial-tracking',
'usb',
]

// const sandboxAllowList = [
// 'allow-same-origin',
// // 'allow-forms',
// // 'allow-fullscreen',
// // 'allow-modals',
// // 'allow-orientation-lock',
// // 'allow-pointer-lock',
// // 'allow-popups',
// // 'allow-popups-to-escape-sandbox',
// // 'allow-presentation',
// // 'allow-scripts',
// // 'allow-top-navigation',
// // 'allow-top-navigation-by-user-activation',
// ]

export type CanvasIframeProps = {
story?: string
exportName?: string
} & Omit<HTMLProps<HTMLIFrameElement>, 'src'>

const handleReceivedMessage = (message: any) => {
// eslint-disable-next-line no-console
console.log('[top] Message received:', message)
}

export function CanvasIframe(props: CanvasIframeProps) {
const userConfig = useStoryLiteConfig()
const userProps = userConfig?.iframeProps || {}
const { className: userClassName, ...userRest } = userProps
const { story, exportName, className, ...rest } = props

const iframeSrc =
story === undefined ? '/#/sandbox/dashboard' : `/#/sandbox/stories/${story}/${exportName || ''}`

const { ref, isReady, sendMessage } = useCanvasIframe(handleReceivedMessage)

const handleToolbarAction = () => {
if (isReady) {
sendMessage({
type: 'chat',
payload: {
message: 'Hello from top window',
},
})
}
}

return (
<>
<button onClick={handleToolbarAction}>Send message to iframe</button>
<iframe
ref={ref}
id={ElementIds.Iframe}
src={iframeSrc}
title="StoryLite-iframe"
className={cn('StoryFrame', className, userClassName)}
allow={allowList.map(permission => `${permission} *`).join('; ')}
allowFullScreen={true}
{...rest}
{...userRest}
/>
</>
)
}
57 changes: 57 additions & 0 deletions packages/storylite/src/components/canvas/CanvasIframeBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { HTMLProps, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { cn } from '@r1stack/core'

import { Story } from '../Story'
import { CrossDocumentMessageSource, sendMessageToRoot } from './useCanvasIframe'

export type CanvasIframeBodyProps = {
story?: string
exportName?: string
} & HTMLProps<HTMLDivElement>

export function CanvasIframeBody(props: CanvasIframeBodyProps) {
const { story, exportName, ...rest } = props

const [searchParams] = useSearchParams()
const isStandalone = searchParams.has('standalone')

useEffect(() => {
const onMessage = (message: any) => {
// eslint-disable-next-line no-console
console.log('[iframe] Message received:', message)
// do something, .e.g.: use global state manager
}
const handleReceivedMessage = (event: MessageEvent) => {
const eventSource = event.data?.source

// Verify that the message comes from the expected sender
if (eventSource === CrossDocumentMessageSource.Root) {
onMessage(event.data)
}
}
window.addEventListener('message', handleReceivedMessage)

return () => {
window.removeEventListener('message', handleReceivedMessage)
}
}, [])

return (
<div className={cn('SandboxLayout', [isStandalone, 'StandaloneSandboxLayout'])} {...rest}>
<button
onClick={() => {
sendMessageToRoot({
type: 'chat',
payload: {
message: 'Hello from iframe',
},
})
}}
>
Send message to main window
</button>
<Story story={story ?? 'index'} exportName={exportName ?? 'default'} />
</div>
)
}
131 changes: 131 additions & 0 deletions packages/storylite/src/components/canvas/useCanvasIframe.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react'

import { act, render, renderHook, waitFor } from '@testing-library/react'

import {
CrossDocumentMessage,
CrossDocumentMessageSource,
useCanvasIframe,
} from './useCanvasIframe'

// Mock postMessage for testing
window.postMessage = jest.fn()
const iframeMock = {
contentWindow: {
postMessage: jest.fn(),
},
addEventListener: jest.fn().mockImplementation((event, cb) => {
if (event === 'load') {
cb()
}
}),
removeEventListener: jest.fn(),
}

describe('useCanvasIframe', () => {
afterEach(() => {
jest.clearAllMocks()
})

const mockUseRef = () => {
jest.spyOn(React, 'useRef').mockReturnValue({ current: iframeMock })
}

const renderIframe = (args: Parameters<typeof useCanvasIframe> = []) => {
const { result } = renderHook(() => useCanvasIframe(...args))
render(
<div>
<iframe
data-testid="test-iframe"
title="test"
onLoad={() => {
result.current.isReady = true
// result.current.ref.current = iframeElementMock as unknown as HTMLIFrameElement
}}
/>
</div>,
)

return result.current
}

const renderMockedIframe = async (args: Parameters<typeof useCanvasIframe> = []) => {
mockUseRef()
const result = renderIframe(args)
await waitFor(() => {
expect(result.isReady).toBe(true)
})

return result
}

test('should set up iframe listeners correctly', async () => {
const result = await renderMockedIframe()

expect(result.isReady).toBe(true)
})

test('should send message to iframe', async () => {
const result = await renderMockedIframe()

const message: CrossDocumentMessage = {
type: 'test',
payload: {
test: 'Hello',
},
}
act(() => {
result.sendMessage(message)
})

expect(iframeMock.contentWindow.postMessage).toHaveBeenCalledWith(
{ ...message, source: CrossDocumentMessageSource.Root },
'/',
)
})

test('should call onMessage callback when message received', async () => {
const onMessage = jest.fn()
await renderMockedIframe([onMessage])

const message: CrossDocumentMessage = {
type: 'test',
payload: {
test: 'Hello',
},
source: CrossDocumentMessageSource.Iframe,
}
const event = new MessageEvent('message', {
source: iframeMock.contentWindow as unknown as Window,
data: message,
})

act(() => {
window.dispatchEvent(event)
})

expect(onMessage).toHaveBeenCalledWith(message)
})

test('should not call onMessage callback if source is not the iframe', async () => {
const onMessage = jest.fn()
await renderMockedIframe([onMessage])

const event = new MessageEvent('message', {
source: null,
data: {
source: 'other' as any,
type: 'test',
payload: {
test: 'Hello',
},
} satisfies CrossDocumentMessage,
})

act(() => {
window.dispatchEvent(event)
})

expect(onMessage).not.toHaveBeenCalled()
})
})
Loading

0 comments on commit afaec92

Please sign in to comment.