/** * Emulates forthcoming HMR hooks in Svelte. * * All references to private component state ($$) are now isolated in this * module. */ import { current_component, get_current_component, set_current_component, } from 'svelte/internal' const captureState = cmp => { // sanity check: propper behaviour here is to crash noisily so that // user knows that they're looking at something broken if (!cmp) { throw new Error('Missing component') } if (!cmp.$$) { throw new Error('Invalid component') } const { $$: { callbacks, bound, ctx, props }, } = cmp const state = cmp.$capture_state() // capturing current value of props (or we'll recreate the component with the // initial prop values, that may have changed -- and would not be reflected in // options.props) const hmr_props_values = {} Object.keys(cmp.$$.props).forEach(prop => { hmr_props_values[prop] = ctx[props[prop]] }) return { ctx, props, callbacks, bound, state, hmr_props_values, } } // remapping all existing bindings (including hmr_future_foo ones) to the // new version's props indexes, and refresh them with the new value from // context const restoreBound = (cmp, restore) => { // reverse prop:ctxIndex in $$.props to ctxIndex:prop // // ctxIndex can be either a regular index in $$.ctx or a hmr_future_ prop // const propsByIndex = {} for (const [name, i] of Object.entries(restore.props)) { propsByIndex[i] = name } // NOTE $$.bound cannot change in the HMR lifetime of a component, because // if bindings changes, that means the parent component has changed, // which means the child (current) component will be wholly recreated for (const [oldIndex, updateBinding] of Object.entries(restore.bound)) { // can be either regular prop, or future_hmr_ prop const propName = propsByIndex[oldIndex] // this should never happen if remembering of future props is enabled... // in any case, there's nothing we can do about it if we have lost prop // name knowledge at this point if (propName == null) continue // NOTE $$.props[propName] also propagates knowledge of a possible // future prop to the new $$.props (via $$.props being a Proxy) const newIndex = cmp.$$.props[propName] cmp.$$.bound[newIndex] = updateBinding // NOTE if the prop doesn't exist or doesn't exist anymore in the new // version of the component, clearing the binding is the expected // behaviour (since that's what would happen in non HMR code) const newValue = cmp.$$.ctx[newIndex] updateBinding(newValue) } } // restoreState // // It is too late to restore context at this point because component instance // function has already been called (and so context has already been read). // Instead, we rely on setting current_component to the same value it has when // the component was first rendered -- which fix support for context, and is // also generally more respectful of normal operation. // const restoreState = (cmp, restore) => { if (!restore) return if (restore.callbacks) { cmp.$$.callbacks = restore.callbacks } if (restore.bound) { restoreBound(cmp, restore) } // props, props.$$slots are restored at component creation (works // better -- well, at all actually) } const get_current_component_safe = () => { // NOTE relying on dynamic bindings (current_component) makes us dependent on // bundler config (and apparently it does not work in demo-svelte-nollup) try { // unfortunately, unlike current_component, get_current_component() can // crash in the normal path (when there is really no parent) return get_current_component() } catch (err) { // ... so we need to consider that this error means that there is no parent // // that makes us tightly coupled to the error message but, at least, we // won't mute an unexpected error, which is quite a horrible thing to do if (err.message === 'Function called outside component initialization') { // who knows... return current_component } else { throw err } } } export const createProxiedComponent = ( Component, initialOptions, { allowLiveBinding, onInstance, onMount, onDestroy } ) => { let cmp let options = initialOptions const isCurrent = _cmp => cmp === _cmp const assignOptions = (target, anchor, restore, preserveLocalState) => { const props = Object.assign({}, options.props) // Filtering props to avoid "unexpected prop" warning // NOTE this is based on props present in initial options, but it should // always works, because props that are passed from the parent can't // change without a code change to the parent itself -- hence, the // child component will be fully recreated, and initial options should // always represent props that are currnetly passed by the parent if (options.props && restore.hmr_props_values) { for (const prop of Object.keys(options.props)) { if (restore.hmr_props_values.hasOwnProperty(prop)) { props[prop] = restore.hmr_props_values[prop] } } } if (preserveLocalState && restore.state) { if (Array.isArray(preserveLocalState)) { // form ['a', 'b'] => preserve only 'a' and 'b' props.$$inject = {} for (const key of preserveLocalState) { props.$$inject[key] = restore.state[key] } } else { props.$$inject = restore.state } } else { delete props.$$inject } options = Object.assign({}, initialOptions, { target, anchor, props, hydrate: false, }) } // Preserving knowledge of "future props" -- very hackish version (maybe // there should be an option to opt out of this) // // The use case is bind:something where something doesn't exist yet in the // target component, but comes to exist later, after a HMR update. // // If Svelte can't map a prop in the current version of the component, it // will just completely discard it: // https://github.com/sveltejs/svelte/blob/1632bca34e4803d6b0e0b0abd652ab5968181860/src/runtime/internal/Component.ts#L46 // const rememberFutureProps = cmp => { if (typeof Proxy === 'undefined') return cmp.$$.props = new Proxy(cmp.$$.props, { get(target, name) { if (target[name] === undefined) { target[name] = 'hmr_future_' + name } return target[name] }, set(target, name, value) { target[name] = value }, }) } const instrument = targetCmp => { const createComponent = (Component, restore, previousCmp) => { set_current_component(parentComponent || previousCmp) const comp = new Component(options) // NOTE must be instrumented before restoreState, because restoring // bindings relies on hacked $$.props instrument(comp) restoreState(comp, restore) return comp } rememberFutureProps(targetCmp) targetCmp.$$.on_hmr = [] // `conservative: true` means we want to be sure that the new component has // actually been successfuly created before destroying the old instance. // This could be useful for preventing runtime errors in component init to // bring down the whole HMR. Unfortunately the implementation bellow is // broken (FIXME), but that remains an interesting target for when HMR hooks // will actually land in Svelte itself. // // The goal would be to render an error inplace in case of error, to avoid // losing the navigation stack (especially annoying in native, that is not // based on URL navigation, so we lose the current page on each error). // targetCmp.$replace = ( Component, { target = options.target, anchor = options.anchor, preserveLocalState, conservative = false, } ) => { const restore = captureState(targetCmp) assignOptions( target || options.target, anchor, restore, preserveLocalState ) const callbacks = cmp ? cmp.$$.on_hmr : [] const afterCallbacks = callbacks.map(fn => fn(cmp)).filter(Boolean) const previous = cmp if (conservative) { try { const next = createComponent(Component, restore, previous) // prevents on_destroy from firing on non-final cmp instance cmp = null previous.$destroy() cmp = next } catch (err) { cmp = previous throw err } } else { // prevents on_destroy from firing on non-final cmp instance cmp = null if (previous) { // previous can be null if last constructor has crashed previous.$destroy() } cmp = createComponent(Component, restore, cmp) } cmp.$$.hmr_cmp = cmp for (const fn of afterCallbacks) { fn(cmp) } cmp.$$.on_hmr = callbacks return cmp } // NOTE onMount must provide target & anchor (for us to be able to determinate // actual DOM insertion point) // // And also, to support keyed list, it needs to be called each time the // component is moved (same as $$.fragment.m) if (onMount) { const m = targetCmp.$$.fragment.m targetCmp.$$.fragment.m = (...args) => { const result = m(...args) onMount(...args) return result } } // NOTE onDestroy must be called even if the call doesn't pass through the // component's $destroy method (that we can hook onto by ourselves, since // it's public API) -- this happens a lot in svelte's internals, that // manipulates cmp.$$.fragment directly, often binding to fragment.d, // for example if (onDestroy) { targetCmp.$$.on_destroy.push(() => { if (isCurrent(targetCmp)) { onDestroy() } }) } if (onInstance) { onInstance(targetCmp) } // Svelte 3 creates and mount components from their constructor if // options.target is present. // // This means that at this point, the component's `fragment.c` and, // most notably, `fragment.m` will already have been called _from inside // createComponent_. That is: before we have a chance to hook on it. // // Proxy's constructor // -> createComponent // -> component constructor // -> component.$$.fragment.c(...) (or l, if hydrate:true) // -> component.$$.fragment.m(...) // // -> you are here <- // if (onMount) { const { target, anchor } = options if (target) { onMount(target, anchor) } } } const parentComponent = allowLiveBinding ? current_component : get_current_component_safe() cmp = new Component(options) cmp.$$.hmr_cmp = cmp instrument(cmp) return cmp }