Spaces:
Runtime error
Runtime error
/* 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 } | |
} | |