You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

428 lines
12 KiB
JavaScript

/* eslint-env browser */
/**
* The HMR proxy is a component-like object whose task is to sit in the
* component tree in place of the proxied component, and rerender each
* successive versions of said component.
*/
import { createProxiedComponent } from './svelte-hooks.js'
const handledMethods = ['constructor', '$destroy']
const forwardedMethods = ['$set', '$on']
const logError = (msg, err) => {
// eslint-disable-next-line no-console
console.error('[HMR][Svelte]', msg)
if (err) {
// NOTE avoid too much wrapping around user errors
// eslint-disable-next-line no-console
console.error(err)
}
}
const posixify = file => file.replace(/[/\\]/g, '/')
const getBaseName = id =>
id
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
const capitalize = str => str[0].toUpperCase() + str.slice(1)
const getFriendlyName = id => capitalize(getBaseName(posixify(id)))
const getDebugName = id => `<${getFriendlyName(id)}>`
const relayCalls = (getTarget, names, dest = {}) => {
for (const key of names) {
dest[key] = function(...args) {
const target = getTarget()
if (!target) {
return
}
return target[key] && target[key].call(this, ...args)
}
}
return dest
}
const isInternal = key => key !== '$$' && key.slice(0, 2) === '$$'
// This is intented as a somewhat generic / prospective fix to the situation
// that arised with the introduction of $$set in Svelte 3.24.1 -- trying to
// avoid giving full knowledge (like its name) of this implementation detail
// to the proxy. The $$set method can be present or not on the component, and
// its presence impacts the behaviour (but with HMR it will be tested if it is
// present _on the proxy_). So the idea here is to expose exactly the same $$
// props as the current version of the component and, for those that are
// functions, proxy the calls to the current component.
const relayInternalMethods = (proxy, cmp) => {
// delete any previously added $$ prop
Object.keys(proxy)
.filter(isInternal)
.forEach(key => {
delete proxy[key]
})
// guard: no component
if (!cmp) return
// proxy current $$ props to the actual component
Object.keys(cmp)
.filter(isInternal)
.forEach(key => {
Object.defineProperty(proxy, key, {
configurable: true,
get() {
const value = cmp[key]
if (typeof value !== 'function') return value
return (
value &&
function(...args) {
return value.apply(this, args)
}
)
},
})
})
}
// proxy custom methods
const copyComponentProperties = (proxy, cmp, previous) => {
if (previous) {
previous.forEach(prop => {
delete proxy[prop]
})
}
const props = Object.getOwnPropertyNames(Object.getPrototypeOf(cmp))
const wrappedProps = props.filter(prop => {
if (!handledMethods.includes(prop) && !forwardedMethods.includes(prop)) {
Object.defineProperty(proxy, prop, {
configurable: true,
get() {
return cmp[prop]
},
set(value) {
// we're changing it on the real component first to see what it
// gives... if it throws an error, we want to throw the same error in
// order to most closely follow non-hmr behaviour.
cmp[prop] = value
},
})
return true
}
})
return wrappedProps
}
// everything in the constructor!
//
// so we don't polute the component class with new members
//
class ProxyComponent {
constructor(
{
Adapter,
id,
debugName,
current, // { Component, hotOptions: { preserveLocalState, ... } }
register,
},
options // { target, anchor, ... }
) {
let cmp
let disposed = false
let lastError = null
const setComponent = _cmp => {
cmp = _cmp
relayInternalMethods(this, cmp)
}
const getComponent = () => cmp
const destroyComponent = () => {
// destroyComponent is tolerant (don't crash on no cmp) because it
// is possible that reload/rerender is called after a previous
// createComponent has failed (hence we have a proxy, but no cmp)
if (cmp) {
cmp.$destroy()
setComponent(null)
}
}
const refreshComponent = (target, anchor, conservativeDestroy) => {
if (lastError) {
lastError = null
adapter.rerender()
} else {
try {
const replaceOptions = {
target,
anchor,
preserveLocalState: current.preserveLocalState,
}
if (conservativeDestroy) {
replaceOptions.conservativeDestroy = true
}
cmp.$replace(current.Component, replaceOptions)
} catch (err) {
setError(err, target, anchor)
if (
!current.hotOptions.optimistic ||
// non acceptable components (that is components that have to defer
// to their parent for rerender -- e.g. accessors, named exports)
// are most tricky, and they havent been considered when most of the
// code has been written... as a result, they are especially tricky
// to deal with, it's better to consider any error with them to be
// fatal to avoid odities
!current.canAccept ||
(err && err.hmrFatal)
) {
throw err
} else {
// const errString = String((err && err.stack) || err)
logError(`Error during component init: ${debugName}`, err)
}
}
}
}
const setError = err => {
lastError = err
adapter.renderError(err)
}
const instance = {
hotOptions: current.hotOptions,
proxy: this,
id,
debugName,
refreshComponent,
}
const adapter = new Adapter(instance)
const { afterMount, rerender } = adapter
// $destroy is not called when a child component is disposed, so we
// need to hook from fragment.
const onDestroy = () => {
// NOTE do NOT call $destroy on the cmp from here; the cmp is already
// dead, this would not work
if (!disposed) {
disposed = true
adapter.dispose()
unregister()
}
}
// ---- register proxy instance ----
const unregister = register(rerender)
// ---- augmented methods ----
this.$destroy = () => {
destroyComponent()
onDestroy()
}
// ---- forwarded methods ----
relayCalls(getComponent, forwardedMethods, this)
// ---- create & mount target component instance ---
try {
let lastProperties
createProxiedComponent(current.Component, options, {
allowLiveBinding: current.hotOptions.allowLiveBinding,
onDestroy,
onMount: afterMount,
onInstance: comp => {
setComponent(comp)
// WARNING the proxy MUST use the same $$ object as its component
// instance, because a lot of wiring happens during component
// initialisation... lots of references to $$ and $$.fragment have
// already been distributed around when the component constructor
// returns, before we have a chance to wrap them (and so we can't
// wrap them no more, because existing references would become
// invalid)
this.$$ = comp.$$
lastProperties = copyComponentProperties(this, comp, lastProperties)
},
})
} catch (err) {
const { target, anchor } = options
setError(err, target, anchor)
throw err
}
}
}
const syncStatics = (component, proxy, previousKeys) => {
// remove previously copied keys
if (previousKeys) {
for (const key of previousKeys) {
delete proxy[key]
}
}
// forward static properties and methods
const keys = []
for (const key in component) {
keys.push(key)
proxy[key] = component[key]
}
return keys
}
const globalListeners = {}
const onGlobal = (event, fn) => {
event = event.toLowerCase()
if (!globalListeners[event]) globalListeners[event] = []
globalListeners[event].push(fn)
}
const fireGlobal = (event, ...args) => {
const listeners = globalListeners[event]
if (!listeners) return
for (const fn of listeners) {
fn(...args)
}
}
const fireBeforeUpdate = () => fireGlobal('beforeupdate')
const fireAfterUpdate = () => fireGlobal('afterupdate')
if (typeof window !== 'undefined') {
window.__SVELTE_HMR = {
on: onGlobal,
}
window.dispatchEvent(new CustomEvent('svelte-hmr:ready'))
}
let fatalError = false
export const hasFatalError = () => fatalError
/**
* Creates a HMR proxy and its associated `reload` function that pushes a new
* version to all existing instances of the component.
*/
export function createProxy({
Adapter,
id,
Component,
hotOptions,
canAccept,
preserveLocalState,
}) {
const debugName = getDebugName(id)
const instances = []
// current object will be updated, proxy instances will keep a ref
const current = {
Component,
hotOptions,
canAccept,
preserveLocalState,
}
const name = `Proxy${debugName}`
// this trick gives the dynamic name Proxy<MyComponent> to the concrete
// proxy class... unfortunately, this doesn't shows in dev tools, but
// it stills allow to inspect cmp.constructor.name to confirm an instance
// is a proxy
const proxy = {
[name]: class extends ProxyComponent {
constructor(options) {
try {
super(
{
Adapter,
id,
debugName,
current,
register: rerender => {
instances.push(rerender)
const unregister = () => {
const i = instances.indexOf(rerender)
instances.splice(i, 1)
}
return unregister
},
},
options
)
} catch (err) {
// If we fail to create a proxy instance, any instance, that means
// that we won't be able to fix this instance when it is updated.
// Recovering to normal state will be impossible. HMR's dead.
//
// Fatal error will trigger a full reload on next update (reloading
// right now is kinda pointless since buggy code still exists).
//
// NOTE Only report first error to avoid too much polution -- following
// errors are probably caused by the first one, or they will show up
// in turn when the first one is fixed ¯\_(ツ)_/¯
//
if (!fatalError) {
fatalError = true
logError(
`Unrecoverable HMR error in ${debugName}: ` +
`next update will trigger a full reload`
)
}
throw err
}
}
},
}[name]
// initialize static members
let previousStatics = syncStatics(current.Component, proxy)
const update = newState => Object.assign(current, newState)
// reload all existing instances of this component
const reload = () => {
fireBeforeUpdate()
// copy statics before doing anything because a static prop/method
// could be used somewhere in the create/render call
previousStatics = syncStatics(current.Component, proxy, previousStatics)
const errors = []
instances.forEach(rerender => {
try {
rerender()
} catch (err) {
logError(`Failed to rerender ${debugName}`, err)
errors.push(err)
}
})
if (errors.length > 0) {
return false
}
fireAfterUpdate()
return true
}
const hasFatalError = () => fatalError
return { id, proxy, update, reload, hasFatalError, current }
}