428 lines
12 KiB
JavaScript
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 }
|
|
}
|