/* 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 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 } }