Skip to content

Commit

Permalink
refactor live reload UI (#92)
Browse files Browse the repository at this point in the history
## Description

Refactors live reload to include more states and fix a few bugs with hot
module reloading (HMR).

### Custom Dev Server

A custom dev server was used to fix HMR due to a bug with Remix v2. A
current issue exists regarding the issue with HMR here:
remix-run/remix#7466.

A current workaround to the issue is to use a custom dev server as
described [in this
comment](remix-run/remix#7466 (comment)).

The sever implementation is based on a [community example for using ES
Modules +
TypeScript](https://github.com/xHomu/remix-v2-server/tree/esm-server.ts).

## Demos

### Added file


![image](https://github.com/chanzuckerberg/cryoet-data-portal/assets/2176050/699d41de-9be1-471f-a0f7-d913c7b8e279)

### Deleted file


![image](https://github.com/chanzuckerberg/cryoet-data-portal/assets/2176050/99f74017-cb04-4681-8b90-4dbd673063e3)

### Modified file

Unlike when a file is added or deleted, the overlay for modified files
only disappear when an HMR event is received from the server. That's why
when the overlay disappears, the UI is immediately updated. If we did it
when the bundle was finished building, there would be a small delay
between the UI being rebuilt and being rerendered.


https://github.com/chanzuckerberg/cryoet-data-portal/assets/2176050/c2d81d16-ef41-4c36-a82a-f69aec471d71

### Multiple files

Editing multiple files during an in progress live reload will prolong
the reload, so it'd be nice to know which files were updated during the
reload on the overlay screen.


https://github.com/chanzuckerberg/cryoet-data-portal/assets/2176050/b85c410e-7c55-449b-92e6-eb3809a23187

### Live reload taking long

Sometimes live reload will appear stuck due to an error that is only
visible within the terminal. Because of this, an additional warning is
shown when live reload takes a long time to complete.

<img width="1728" alt="image"
src="https://github.com/chanzuckerberg/cryoet-data-portal/assets/2176050/94277c44-1087-49ba-ab3f-3c7470450226">

### Websocket closed

When the dev server is closed, the frontend will lose it's connection to
the dev server's websocket connection. We show an overlay when this
happens to make it clear that the dev server isn't running.

<img width="1728" alt="image"
src="https://github.com/chanzuckerberg/cryoet-data-portal/assets/2176050/28261614-331f-4d5b-8c82-2024a006ed18">
  • Loading branch information
codemonkey800 authored Oct 26, 2023
1 parent d57d594 commit 4b07272
Show file tree
Hide file tree
Showing 8 changed files with 915 additions and 539 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LiveReloadEvent } from './event'
import { LIVE_RELOAD_EVENT, LiveReloadEventType } from './event'

/**
* LiveReload component inlined from remix-run/react:
Expand Down Expand Up @@ -26,33 +26,53 @@ export const LiveReload =
dangerouslySetInnerHTML={{
__html: js`
function remixLiveReloadConnect(config) {
let REMIX_DEV_ORIGIN = ${JSON.stringify(
const REMIX_DEV_ORIGIN = ${JSON.stringify(
process.env.REMIX_DEV_ORIGIN,
)}
let protocol =
const protocol =
REMIX_DEV_ORIGIN ? new URL(REMIX_DEV_ORIGIN).protocol.replace(/^http/, "ws") :
location.protocol === "https:" ? "wss:" : "ws:" // remove in v2?
let hostname = REMIX_DEV_ORIGIN ? new URL(REMIX_DEV_ORIGIN).hostname : location.hostname
let url = new URL(protocol + "//" + hostname + "/socket")
const hostname = REMIX_DEV_ORIGIN ? new URL(REMIX_DEV_ORIGIN).hostname : location.hostname
const url = new URL(protocol + "//" + hostname + "/socket")
url.port =
${port} ||
(REMIX_DEV_ORIGIN ? new URL(REMIX_DEV_ORIGIN).port : 8002)
let ws = new WebSocket(url.href)
const ws = new WebSocket(url.href)
const logEvent = (data) => window.dispatchEvent(
new CustomEvent(
'${LIVE_RELOAD_EVENT}',
{ detail: data },
),
)
ws.onmessage = async (message) => {
let event = JSON.parse(message.data)
const event = JSON.parse(message.data)
if (event.type === "LOG") {
console.log(event.message)
if (event.message.includes('file changed')) {
window.dispatchEvent(new CustomEvent('${
LiveReloadEvent.Started
}'))
const match = /file (created|changed|deleted): (.*)/.exec(event.message)
if (match) {
const [, action, file] = match
logEvent({
type: '${LiveReloadEventType.Started}',
action,
file,
})
// because changing files may trigger an update, we
// defer sending the "Completed" event until the HMR
// event is processed.
if (action !== 'changed' && event.message.includes('rebuilt')) {
logEvent({ type: '${LiveReloadEventType.Completed}' })
}
}
}
Expand All @@ -68,7 +88,10 @@ export const LiveReload =
return
}
if (!event.updates || !event.updates.length) return
if (!event.updates || !event.updates.length) {
logEvent({ type: '${LiveReloadEventType.Completed}' })
return
}
let updateAccepted = false
let needsRevalidation = new Set()
Expand Down Expand Up @@ -110,9 +133,7 @@ export const LiveReload =
window.location.reload()
}
window.dispatchEvent(new CustomEvent('${
LiveReloadEvent.Completed
}'))
logEvent({ type: '${LiveReloadEventType.Completed}' })
}
}
Expand All @@ -121,14 +142,14 @@ export const LiveReload =
config.onOpen()
}
window.dispatchEvent(new CustomEvent('${
LiveReloadEvent.Connected
}'))
logEvent({ type: '${LiveReloadEventType.Connected}' })
}
ws.onclose = (event) => {
let message = ''
if (event.code === 1006) {
console.log("Remix dev asset server web socket closed. Reconnecting...")
message = 'Remix dev asset server web socket closed. Reconnecting...'
console.log(message)
setTimeout(
() =>
Expand All @@ -139,20 +160,26 @@ export const LiveReload =
)
}
window.dispatchEvent(new CustomEvent('${
LiveReloadEvent.Closed
}'))
logEvent({
type: '${LiveReloadEventType.Closed}',
code: event.code,
...(message ? { message } : {})
})
}
ws.onerror = (error) => {
console.log("Remix dev asset server web socket error:")
const errorMessage = "Remix dev asset server web socket error:"
console.log(errorMessage)
console.error(error)
window.dispatchEvent(new CustomEvent('${
LiveReloadEvent.Error
}', { detail: error }))
logEvent({
type: '${LiveReloadEventType.Error}',
message: errorMessage,
error: error instanceof Error ? error.message : String(error)
})
}
}
remixLiveReloadConnect()
`,
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { useTimeoutEffect } from '@react-hookz/web'
import { motion } from 'framer-motion'
import { isEqual } from 'lodash-es'
import { useEffect, useState } from 'react'

import { Overlay } from 'app/components/Overlay'

import { LiveReloadEvent } from './event'
import {
LIVE_RELOAD_EVENT,
LiveReloadEvent,
LiveReloadEventType,
LiveReloadStartedEventData,
} from './event'

/**
* Overlay that is shown when live reloading is in progress after a file has been changed.
Expand All @@ -12,31 +20,128 @@ export const LiveReloadOverlay =
? () => null
: function LiveReloadOverlay() {
const [visible, setVisible] = useState(false)
const [eventData, setEventData] = useState<
LiveReloadStartedEventData[]
>([])

const [showBuildFailureWarning, setShowBuildFailureWarning] =
useState(false)

const [websocketClosedReason, setWebsocketClosedReason] = useState('')

const [, reset] = useTimeoutEffect(
() => setShowBuildFailureWarning(true),
visible ? 5000 : undefined,
)

useEffect(() => {
function enableOverlay() {
setVisible(true)
}
function handleLiveReloadEvent(event: Event) {
const { detail } = event as CustomEvent<LiveReloadEvent>

function disableOverlay() {
setVisible(false)
}
switch (detail.type) {
case LiveReloadEventType.Started:
reset()
setVisible(true)
setEventData((prev) => {
const next = {
action: detail.action,
file: detail.file,
}

return prev
.filter((data) => !isEqual(data, next))
.concat(next)
})
break

case LiveReloadEventType.Closed:
setVisible(true)
if (detail.message) {
setWebsocketClosedReason(detail.message)
} else {
setWebsocketClosedReason(`Code: ${detail.code}`)
}

window.addEventListener(LiveReloadEvent.Started, enableOverlay)
window.addEventListener(LiveReloadEvent.Completed, disableOverlay)
break

return () => {
window.removeEventListener(LiveReloadEvent.Started, enableOverlay)
window.removeEventListener(
LiveReloadEvent.Completed,
disableOverlay,
)
case LiveReloadEventType.Completed:
setVisible(false)
setEventData([])
break

default:
break
}
}
}, [])

window.addEventListener(LIVE_RELOAD_EVENT, handleLiveReloadEvent)

return () =>
window.removeEventListener(LIVE_RELOAD_EVENT, handleLiveReloadEvent)
}, [reset])

return (
<Overlay open={visible}>
<p className="text-5xl text-white">Live Reloading...</p>
<motion.div
layout
className="flex flex-col gap-2 max-w-[700px] items-center text-center"
>
{websocketClosedReason ? (
<>
<motion.p layout className="text-5xl text-white font-bold">
Websocket Closed
</motion.p>

<motion.p
layout
className="text-2xl text-white font-medium mt-3"
>
{websocketClosedReason}
</motion.p>
</>
) : (
<>
<motion.p
layout
className="text-5xl text-white font-bold mb-4"
>
Live Reloading...
</motion.p>

{eventData.length > 0 && (
<>
{eventData.map((data) => (
<motion.p
layout
key={data.action + data.file}
className="text-xl text-white font-medium"
>
{data.action}: {data.file}
</motion.p>
))}
</>
)}

{showBuildFailureWarning && (
<>
<motion.p
layout
className="text-lg text-white font-medium mt-4"
>
Live reload is taking longer than usual.
</motion.p>

<motion.p
layout
className="text-lg text-white font-medium"
>
Please check your terminal for build errors.
</motion.p>
</>
)}
</>
)}
</motion.div>
</Overlay>
)
}
52 changes: 43 additions & 9 deletions frontend/packages/data-portal/app/components/LiveReload/event.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
/**
* Enum for different events in the live reload websocket.
*/
export enum LiveReloadEvent {
Closed = 'live-reload-closed',
Completed = 'live-reload-completed',
Connected = 'live-reload-connected',
Error = 'live-reload-error',
Started = 'live-reload-started',
export const LIVE_RELOAD_EVENT = 'live-reload-event'

export enum LiveReloadEventType {
Closed = 'closed',
Completed = 'completed',
Connected = 'connected',
Error = 'error',
Started = 'started',
}

interface LiveReloadClosedEvent {
type: LiveReloadEventType.Closed
code: string
message?: string
}

interface LiveReloadCompletedEvent {
type: LiveReloadEventType.Completed
}

interface LiveReloadConnectedEvent {
type: LiveReloadEventType.Connected
}

interface LiveReloadErrorEvent {
type: LiveReloadEventType.Error
message: string
error: string
}

interface LiveReloadStartedEvent {
type: LiveReloadEventType.Started
action: 'created' | 'changed' | 'deleted'
file: string
}

export type LiveReloadStartedEventData = Omit<LiveReloadStartedEvent, 'type'>

export type LiveReloadEvent =
| LiveReloadClosedEvent
| LiveReloadCompletedEvent
| LiveReloadConnectedEvent
| LiveReloadErrorEvent
| LiveReloadStartedEvent
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function Overlay({
<AnimatePresence>
{open && (
<motion.div
className="bg-black/60 flex items-center fixed top-0 left-0 justify-center w-screen h-screen"
className="bg-black/75 flex items-center fixed top-0 left-0 justify-center w-screen h-screen z-[999]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
Expand Down
Loading

0 comments on commit 4b07272

Please sign in to comment.