diff --git a/index.js b/index.js index 320a12ce..c2029fc5 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,14 @@ const { basename, extname, relative } = require('path'); const { getOptions } = require('loader-utils'); const VirtualModules = require('./lib/virtual'); +const posixify = require('./lib/posixify'); -const hotApi = require.resolve('./lib/hot-api.js'); - -const { version } = require('svelte/package.json'); -const major_version = +version[0]; -const { compile, preprocess } = major_version >= 3 - ? require('svelte/compiler') - : require('svelte'); +const { + major_version, + compile, + preprocess, + makeHot, +} = require('./lib/resolve-svelte'); const pluginOptions = { externalDependencies: true, @@ -25,34 +25,6 @@ const pluginOptions = { markup: true }; -function makeHot(id, code, hotOptions) { - const options = JSON.stringify(hotOptions); - const replacement = ` -if (module.hot) { - const { configure, register, reload } = require('${posixify(hotApi)}'); - - module.hot.accept(); - - if (!module.hot.data) { - // initial load - configure(${options}); - $2 = register(${id}, $2); - } else { - // hot update - $2 = reload(${id}, $2); - } -} - -export default $2; -`; - - return code.replace(/(export default ([^;]*));/, replacement); -} - -function posixify(file) { - return file.replace(/[/\\]/g, '/'); -} - function sanitize(input) { return basename(input) .replace(extname(input), '') @@ -106,7 +78,7 @@ module.exports = function(source, map) { const virtualModules = virtualModuleInstances.get(this._compiler); this.cacheable(); - + const options = Object.assign({}, getOptions(this)); const callback = this.async(); diff --git a/lib/make-hot.js b/lib/make-hot.js new file mode 100644 index 00000000..99a021f1 --- /dev/null +++ b/lib/make-hot.js @@ -0,0 +1,29 @@ +const posixify = require('./posixify'); + +const hotApi = require.resolve('./hot-api.js'); + +function makeHot(id, code, hotOptions) { + const options = JSON.stringify(hotOptions); + const replacement = ` +if (module.hot) { + const { configure, register, reload } = require('${posixify(hotApi)}'); + + module.hot.accept(); + + if (!module.hot.data) { + // initial load + configure(${options}); + $2 = register(${id}, $2); + } else { + // hot update + $2 = reload(${id}, $2); + } +} + +export default $2; +`; + + return code.replace(/(export default ([^;]*));/, replacement); +} + +module.exports = makeHot; diff --git a/lib/posixify.js b/lib/posixify.js new file mode 100644 index 00000000..84d804b5 --- /dev/null +++ b/lib/posixify.js @@ -0,0 +1,3 @@ +module.exports = function posixify(file) { + return file.replace(/[/\\]/g, '/'); +}; diff --git a/lib/resolve-svelte.js b/lib/resolve-svelte.js new file mode 100644 index 00000000..b6384961 --- /dev/null +++ b/lib/resolve-svelte.js @@ -0,0 +1,39 @@ +const path = require('path'); + +const resolveSvelte = () => { + const { SVELTE } = process.env; + + const absolute = (name, base) => + path.isAbsolute(name) ? name : path.join(base, name); + + if (SVELTE) { + return { + req: require, + base: absolute(SVELTE, process.cwd()), + }; + } else { + return { + req: require.main.require.bind(require.main), + base: 'svelte', + }; + } +}; + +const { req, base } = resolveSvelte(); + +const { version } = req(`${base}/package.json`); + +const major_version = +version[0]; + +const { compile, preprocess } = + major_version >= 3 ? req(`${base}/compiler`) : req(`${base}`); + +const makeHot = + major_version >= 3 ? require('./svelte3/make-hot') : require('./make-hot'); + +module.exports = { + major_version, + compile, + preprocess, + makeHot, +}; diff --git a/lib/svelte-native/hot/patch-page-show-modal.js b/lib/svelte-native/hot/patch-page-show-modal.js new file mode 100644 index 00000000..6cb5bd8a --- /dev/null +++ b/lib/svelte-native/hot/patch-page-show-modal.js @@ -0,0 +1,63 @@ +/* global Symbol */ + +// This module monkey patches Page#showModal in order to be able to +// access from the HMR proxy data passed to `showModal` in svelte-native. +// +// Data are stored in a opaque prop accessible with `getModalData`. +// +// It also switches the `closeCallback` option with a custom brewed one +// in order to give the proxy control over when its own instance will be +// destroyed. +// +// Obviously this method suffer from extreme coupling with the target code +// in svelte-native. So it would be wise to recheck compatibility on SN +// version upgrades. +// +// Relevant code is there (last checked version): +// +// https://github.com/halfnelson/svelte-native/blob/08702e6b178644f43052f6ec0a789a51e800d21b/src/dom/svelte/StyleElement.ts +// + +// FIXME should we override ViewBase#showModal instead? +import { Page } from 'tns-core-modules/ui/page'; + +const prop = + typeof Symbol !== 'undefined' + ? Symbol('hmr_svelte_native_modal') + : '___HMR_SVELTE_NATIVE_MODAL___'; + +const sup = Page.prototype.showModal; + +let patched = false; + +export const patchShowModal = () => { + // guard: already patched + if (patched) return; + patched = true; + + Page.prototype.showModal = function(modalView, options) { + const modalData = { + originalOptions: options, + closeCallback: options.closeCallback, + }; + + modalView[prop] = modalData; + + // Proxies to a function that can be swapped on the fly by HMR proxy. + // + // The default is still to call the original closeCallback from svelte + // navtive, which will destroy the modal view & component. This way, if + // no HMR happens on the modal content, normal behaviour is preserved + // without the proxy having any work to do. + // + const closeCallback = (...args) => { + return modalData.closeCallback(...args); + }; + + const temperedOptions = Object.assign({}, options, { closeCallback }); + + return sup.call(this, modalView, temperedOptions); + }; +}; + +export const getModalData = modalView => modalView[prop]; diff --git a/lib/svelte-native/hot/proxy-adapter-native.js b/lib/svelte-native/hot/proxy-adapter-native.js new file mode 100644 index 00000000..91019f46 --- /dev/null +++ b/lib/svelte-native/hot/proxy-adapter-native.js @@ -0,0 +1,304 @@ +/* global document */ + +import ProxyAdapterDom from '../../svelte3/hot/proxy-adapter-dom'; + +import { getModalData } from './patch-page-show-modal'; + +// Svelte Native support +// ===================== +// +// Rerendering Svelte Native page proves challenging... +// +// In NativeScript, pages are the top level component. They are normally +// introduced into NativeScript's runtime by its `navigate` function. This +// is how Svelte Natives handles it: it renders the Page component to a +// dummy fragment, and "navigate" to the page element thus created. +// +// As long as modifications only impact child components of the page, then +// we can keep the existing page and replace its content for HMR. +// +// However, if the page component itself is modified (including its system +// title bar), things get hairy... +// +// Apparently, the sole way of introducing a new page in a NS application is +// to navigate to it (no way to just replace it in its parent "element", for +// example). This is how it is done in NS's own "core" HMR. +// +// Unfortunately the API they're using to do that is not public... Its various +// parts remain exposed though (but documented as private), so this exploratory +// work now relies on it. It might be fragile... +// +// The problem is that there is no public API that can navigate to a page and +// replacing (like location.replace) the current history entry. Actually there +// is an active issue at NS asking for that. Incidentally, members of +// NativeScript-Vue have commented on the issue to weight in for it -- they +// probably face some similar challenge. + +// svelte-native uses navigateFrom event + e.isBackNavigation to know +// when to $destroy the component -- but we don't want our proxy instance +// destroyed when we renavigate to the same page for navigation purposes! +const interceptPageNavigation = pageElement => { + const originalNativeView = pageElement.nativeView; + const { on } = originalNativeView; + const ownOn = originalNativeView.hasOwnProperty('on'); + // tricks svelte-native into giving us its handler + originalNativeView.on = function(type, handler, ...rest) { + if (type === 'navigatedFrom') { + this.navigatedFromHandler = handler; + if (ownOn) { + originalNativeView.on = on; + } else { + delete originalNativeView.on; + } + } else { + throw new Error( + 'Unexpected call: has underlying svelte-native code changed?' + ); + } + }; +}; + +const getNavTransition = ({ transition }) => { + if (typeof transition === 'string') { + transition = { name: transition }; + } + return transition ? { animated: true, transition } : { animated: false }; +}; + +export default class ProxyAdapterNative extends ProxyAdapterDom { + constructor(instance) { + super(instance); + + this.nativePageElement = null; + this.originalNativeView = null; + this.navigatedFromHandler = null; + + this.relayNativeNavigatedFrom = this.relayNativeNavigatedFrom.bind(this); + } + + dispose() { + super.dispose(); + this.releaseNativePageElement(); + } + + releaseNativePageElement() { + if (this.nativePageElement) { + // native cleaning will happen when navigating back from the page + this.nativePageElement = null; + } + } + + afterMount(target, anchor) { + // nativePageElement needs to be updated each time (only for page + // components, native component that are not pages follow normal flow) + // + // DEBUG quid of components that are initially a page, but then have the + // tag removed while running? or the opposite? + // + // insertionPoint needs to be updated _only when the target changes_ -- + // i.e. when the component is mount, i.e. (in svelte3) when the component + // is _created_, and svelte3 doesn't allow it to move afterward -- that + // is, insertionPoint only needs to be created once when the component is + // first mounted. + // + // DEBUG is it really true that components' elements cannot move in the + // DOM? what about keyed list? + // + const isNativePage = target.tagName === 'fragment'; + if (isNativePage) { + const nativePageElement = target.firstChild; + interceptPageNavigation(nativePageElement); + this.nativePageElement = nativePageElement; + } else { + // try to protect against components changing from page to no-page + // or vice versa -- see DEBUG 1 above. NOT TESTED so prolly not working + this.nativePageElement = null; + super.afterMount(target, anchor); + } + } + + rerender() { + const { nativePageElement } = this; + if (nativePageElement) { + this.rerenderNative(); + } else { + super.rerender(); + } + } + + rerenderNative() { + const { nativePageElement: oldPageElement } = this; + const nativeView = oldPageElement.nativeView; + const frame = nativeView.frame; + if (frame) { + return this.rerenderPage(frame, nativeView); + } + const modalParent = nativeView._modalParent; // FIXME private API + if (modalParent) { + return this.rerenderModal(modalParent, nativeView); + } + // wtf? hopefully a race condition with a destroyed component, so + // we have nothing more to do here + // + // for once, it happens when hot reloading dev deps, like this file + // + } + + rerenderPage(frame, previousPageView) { + const isCurrentPage = frame.currentPage === previousPageView; + if (isCurrentPage) { + const { + instance: { hotOptions }, + } = this; + const newPageElement = this.createPage(); + if (!newPageElement) { + throw new Error('Failed to create updated page'); + } + const isFirstPage = !frame.canGoBack(); + + if (isFirstPage) { + // The "replacePage" strategy does not work on the first page + // of the stack. + // + // Resulting bug: + // - launch + // - change first page => HMR + // - navigate to other page + // - back + // => actual: back to OS + // => expected: back to page 1 + // + // Fortunately, we can overwrite history in this case. + // + const nativeView = newPageElement.nativeView; + frame.navigate( + Object.assign( + {}, + { + create: () => nativeView, + clearHistory: true, + }, + getNavTransition(hotOptions) + ) + ); + } else { + // copied from TNS FrameBase.replacePage + // + // it is not public but there is a comment in there indicating + // it is for HMR (probably their own core HMR though) + // + // frame.navigationType = NavigationType.replace; + const currentBackstackEntry = frame._currentEntry; + frame.navigationType = 2; + frame.performNavigation({ + isBackNavigation: false, + entry: { + resolvedPage: newPageElement.nativeView, + // + // entry: currentBackstackEntry.entry, + entry: Object.assign( + currentBackstackEntry.entry, + getNavTransition(hotOptions) + ), + navDepth: currentBackstackEntry.navDepth, + fragmentTag: currentBackstackEntry.fragmentTag, + frameId: currentBackstackEntry.frameId, + }, + }); + } + } else { + const backEntry = frame.backStack.find( + ({ resolvedPage: page }) => page === previousPageView + ); + if (!backEntry) { + // well... looks like we didn't make it to history after all + return; + } + // replace existing nativeView + const newPageElement = this.createPage(); + if (newPageElement) { + backEntry.resolvedPage = newPageElement.nativeView; + } else { + throw new Error('Failed to create updated page'); + } + } + } + + // modalParent is the page on which showModal(...) was called + // oldPageElement is the modal content, that we're actually trying to reload + rerenderModal(modalParent, modalView) { + const modalData = getModalData(modalView); + + modalData.closeCallback = () => { + const nativePageElement = this.createPage(); + if (!nativePageElement) { + throw new Error('Failed to created updated modal page'); + } + const { nativeView } = nativePageElement; + const { originalOptions } = modalData; + // Options will get monkey patched again, the only work left for us + // is to try to reduce visual disturbances. + // + // FIXME Even that proves too much unfortunately... Apparently TNS + // does not respect the `animated` option in this context: + // https://docs.nativescript.org/api-reference/interfaces/_ui_core_view_base_.showmodaloptions#animated + // + const options = Object.assign({}, originalOptions, { animated: false }); + modalParent.showModal(nativeView, options); + }; + + modalView.closeModal(); + } + + createPage() { + const { + instance: { refreshComponent }, + } = this; + const { nativePageElement, relayNativeNavigatedFrom } = this; + const oldNativeView = nativePageElement.nativeView; + // rerender + const target = document.createElement('fragment'); + let haveNewPage = false; + //if the old page was destroyed then we can be sure we have a new page element. + refreshComponent(target, null, () => { + haveNewPage = true; + }); + if (!haveNewPage) return; + + this.releaseNativePageElement(); + this.afterMount(target); // udpates nativePageElement + const newPageElement = this.nativePageElement; + // update event proxy + oldNativeView.off('navigatedFrom', relayNativeNavigatedFrom); + nativePageElement.nativeView.on('navigatedFrom', relayNativeNavigatedFrom); + return newPageElement; + } + + relayNativeNavigatedFrom({ isBackNavigation }) { + const { originalNativeView, navigatedFromHandler } = this; + if (!isBackNavigation) { + return; + } + if (originalNativeView) { + const { off } = originalNativeView; + const ownOff = originalNativeView.hasOwnProperty('off'); + originalNativeView.off = function() { + this.navigatedFromHandler = null; + if (ownOff) { + originalNativeView.off = off; + } else { + delete originalNativeView.off; + } + }; + } + if (navigatedFromHandler) { + return navigatedFromHandler.apply(this, arguments); + } + } + + renderError(err, target, anchor) { + // TODO fallback on TNS error handler for now... at least our error + // is more informative + throw err; + } +} diff --git a/lib/svelte3/hot/hot-api.js b/lib/svelte3/hot/hot-api.js new file mode 100644 index 00000000..3fa379fd --- /dev/null +++ b/lib/svelte3/hot/hot-api.js @@ -0,0 +1,75 @@ +import DomAdapter from './proxy-adapter-dom'; +import { createProxy } from './proxy'; + +const defaultHotOptions = { + noPreserveState: false, +}; + +const registry = new Map(); + +// One stop shop for HMR updates. Combines functionality of `configure`, +// `register`, and `reload`, based on current registry state. +// +// Additionaly does whatever it can to avoid crashing on runtime errors, +// and tries to decline HMR if that doesn't go well. +// +export function applyHMR( + hotOptions, + id, + moduleHot, + component, + ProxyAdapter = DomAdapter +) { + // resolve existing record + let record = registry.get(id); + let broken = false; + + hotOptions = Object.assign({}, defaultHotOptions, hotOptions); + + // (re)render + if (record) { + const success = record.reload({ component, hotOptions }); + if (success === false) { + broken = true; + } + } else { + record = createProxy(ProxyAdapter, id, component, hotOptions); + registry.set(id, record); + } + + const proxy = record && record.proxy; + + if (!proxy) { + // well, endgame... we won't be able to render next updates, even + // successful, if we don't have proxies in svelte's tree + // + // full reload required + // + // we can't command a full reload from here + + // tell webpack our HMR is dead, so next update should trigger a full reload + moduleHot.decline(); + + // TODO report error on client + + // since we won't return the proxy and the app will expect a svelte + // component, it's gonna crash... so it's best to report the real cause + throw new Error(`Failed to create HMR proxy for Svelte component ${id}`); + } + + // Make sure we won't try to restore from an irrecuperable state. + // + // E.g. a component can partially render children to DOM from its + // constructor, then crash without even leaving a reference in a + // variable (since crash from the constructor). Maybe the compiler + // could handle the situation, but we can't (so, according to HMR + // Tao, better full reload than stale display). + // + if (broken) { + moduleHot.decline(); + } else { + moduleHot.accept(); + } + + return proxy; +} diff --git a/lib/svelte3/hot/proxy-adapter-dom.js b/lib/svelte3/hot/proxy-adapter-dom.js new file mode 100644 index 00000000..6fcddda3 --- /dev/null +++ b/lib/svelte3/hot/proxy-adapter-dom.js @@ -0,0 +1,99 @@ +/* global document */ + +const removeElement = el => el && el.parentNode && el.parentNode.removeChild(el); + +export default class ProxyAdapterDom { + constructor(instance) { + this.instance = instance; + this.insertionPoint = null; + + this.afterMount = this.afterMount.bind(this); + this.rerender = this.rerender.bind(this); + } + + dispose() { + // Component is being destroyed, detaching is not optional in Svelte3's + // component API, so we can dispose of the insertion point in every case. + if (this.insertionPoint) { + removeElement(this.insertionPoint); + this.insertionPoint = null; + } + } + + afterMount(target, anchor) { + const { + instance: { debugName }, + } = this; + // insertionPoint needs to be updated _only when the target changes_ -- + // i.e. when the component is mounted, i.e. (in svelte3) when the component + // is _created_, and svelte3 doesn't allow it to move afterward -- that + // is, insertionPoint only needs to be created once when the component is + // first mounted. + // + // DEBUG is it really true that components' elements cannot move in the + // DOM? what about keyed list? + // + if (!this.insertionPoint) { + this.insertionPoint = document.createComment(debugName); + target.insertBefore(this.insertionPoint, anchor); + } + } + + rerender() { + const { + instance: { refreshComponent }, + insertionPoint, + } = this; + if (!insertionPoint) { + throw new Error('Cannot rerender: Missing insertion point'); + } + refreshComponent(insertionPoint.parentNode, insertionPoint); + } + + renderError(err, target, anchor) { + const { + instance: { debugName }, + } = this; + const style = { + section: ` + border: 3px solid red; + background-color: #fee; + padding: 12px; + `, + h2: ` + margin: 0 0 12px; + color: red; + `, + pre: ` + background: #eee; + padding: 6px; + margin: 0; + border: 1px solid #ddd; + `, + }; + const title = debugName || err.moduleName || 'Error'; + const h2 = document.createElement('h2'); + h2.textContent = title; + const pre = document.createElement('pre'); + pre.textContent = err.stack || err; + const section = document.createElement('section'); + section.appendChild(h2); + section.appendChild(pre); + // style + section.style = style.section; + h2.style = style.h2; + pre.style = style.pre; + if (anchor) { + target.insertBefore(section, anchor); + } else { + if (!target) { + target = document.body; + section.style.posititon = 'absolute'; + } + target.appendChild(section); + } + return () => { + target.removeChild(section); + }; + } +} diff --git a/lib/svelte3/hot/proxy.js b/lib/svelte3/hot/proxy.js new file mode 100644 index 00000000..3bc90ae6 --- /dev/null +++ b/lib/svelte3/hot/proxy.js @@ -0,0 +1,421 @@ +import { hookComponent, restoreState, captureState } from './svelte-hooks'; + +const handledMethods = ['constructor', '$destroy']; +const forwardedMethods = ['$set', '$on']; + +const noop = () => {}; + +const logError = (...args) => console.error('[HMR][Svelte]', ...args); + +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 copyComponentMethods = (proxy, cmp, debugName) => { + //proxy custom methods + const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(cmp)); + methods.forEach(method => { + if ( + !handledMethods.includes(method) && + !forwardedMethods.includes(method) + ) { + proxy[method] = function() { + if (cmp[method]) { + return cmp[method].apply(this, arguments); + } else { + // we can end up here when calling a method added by a previous + // version of the component, then removed (but still called + // somewhere else in the code) + // + // TODO we should possibly consider removing all the methods that + // have been added by a previous version of the component. This + // would be better memory-wise. Not so much so complexity-wise, + // though. And for now, we can't survive most runtime errors, so + // we will be reloaded often... + // + throw new Error( + `Called to undefined method on ${debugName}: ${method}` + ); + } + }; + } + }); +}; + +// TODO clean this extremely ad-hoc, coupled, & fragile code +// TODO native: this must respect Page/Frame interface... or need tolerance from SN +const createErrorComponent = (adapter, err, target, anchor) => { + const cmp = { + $destroy: noop, + $set: noop, + $$: { + fragment: { + c: noop, // create + l: noop, // claim + h: noop, // hydrate + m: (target, anchor) => { + cmp.$destroy = adapter.renderError(err, target, anchor); + }, // mount + p: noop, // update + i: noop, // intro + o: noop, // outro + d: noop, // destroy + }, + ctx: {}, + // state + props: [], + update: noop, + not_equal: noop, + bound: {}, + // lifecycle + on_mount: [], + on_destroy: [], + before_render: [], + after_render: [], + context: {}, + // everything else + callbacks: [], + dirty: noop, + }, + }; + if (target) { + cmp.$destroy = adapter.renderError(err, target, anchor); + } + return cmp; +}; + +// everything in the constructor! +// +// so we don't polute the component class with new members +// +// specificity & conformance with Svelte component constructor is achieved +// in the "component level" (as opposed "instance level") createRecord +// +class ProxyComponent { + constructor( + { + Adapter, + id, + debugName, + current, // { component, hotOptions: { noPreserveState, ... } } + register, + unregister, + reportError, + }, + options // { target, anchor, ... } + ) { + let cmp; + let disposed = false; + let restore; + let lastError = null; + + // it's better to restore props from the very beginning -- for example + // slots (yup, stored in props as $$slots) are broken if not present at + // component creation and later restored with $set + const restoreProps = restore => { + return restore && restore.props && { props: restore.props }; + }; + + const doCreateComponent = (target, anchor) => { + const { component } = current; + const opts = Object.assign( + {}, + options, + { target, anchor }, + restoreProps(restore) + ); + cmp = new component(opts); + }; + + const attachComponent = cmp => { + const onDestroy = () => { + if (cmp === getComponent()) { + afterDetach(); + } + }; + + hookComponent(this, cmp, { + onDestroy, + onMount: afterMount, + }); + + copyComponentMethods(this, cmp); + + restoreState(cmp, restore); + }; + + const createComponent = (target, anchor) => { + doCreateComponent(target, anchor); + attachComponent(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) { + const target = cmp; + // WARNING nullify BEFORE $destroy, or we'll consider we're destroying + // the final component instance, in the onDestroy hook + cmp = null; + target.$destroy(); + } + }; + + const refreshComponent = (target, anchor, conservativeDestroy) => { + if (lastError) { + clearError(); + } else if (conservativeDestroy) { + const prevCmp = cmp; + restore = captureState(cmp); + try { + createComponent(target, anchor); + prevCmp.$destroy(); + if (typeof conservativeDestroy === 'function') { + conservativeDestroy(); + } + } catch (err) { + logError( + `Failed to recreate ${debugName} instance: ${(err && err.stack) || + err}` + ); + cmp = prevCmp; + } + } else { + restore = captureState(cmp); + destroyComponent(); + try { + createComponent(target, anchor); + } catch (err) { + logError( + `Failed to recreate ${debugName} instance: ${(err && err.stack) || + err}` + ); + if (current.hotOptions.optimistic) { + setError(err, target, anchor); + } else { + throw err; + } + } + } + }; + + const setError = (err, target, anchor) => { + lastError = err; + destroyComponent(); + // create a noop comp to trap Svelte's calls + cmp = createErrorComponent(adapter, err, target, anchor); + }; + + const clearError = () => { + lastError = null; + adapter.rerender(); + }; + + const instance = { + hotOptions: current.hotOptions, + proxy: this, + id, + debugName, + refreshComponent, + }; + + if (current.hotOptions.noPreserveState) { + instance.captureState = noop; + instance.restoreState = noop; + } + + 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 afterDetach = () => { + // 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 ---- + + register(rerender); + + // ---- augmented methods ---- + + this.$destroy = () => { + destroyComponent(); + afterDetach(); + }; + + // ---- forwarded methods ---- + + const getComponent = () => cmp; + + relayCalls(getComponent, forwardedMethods, this); + + // ---- create & mount target component instance --- + + { + const { component } = current; + const { target, anchor } = options; + + // copy statics before doing anything because a static prop/method + // could be used somewhere in the create/render call + copyStatics(component, this); + + try { + createComponent(target, anchor); + + // 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 <- + // + // I've tried to move the responsibility for mounting the component here, + // by setting `$$inline` option to prevent Svelte from doing it itself. + // `$$inline` is normally used for child components, and their lifecycle + // is managed by their parent. But that didn't go too well. + // + // We want the proxied component to be mounted on the DOM anyway, so it's + // easier to let Svelte do its things and manually execute our `afterMount` + // hook ourself (will need to do the same for `c` and `l` hooks, if we + // come to need them here). + // + if (target) { + afterMount(target, anchor); + } + } catch (err) { + if (current.hotOptions.optimistic) { + logError( + `Failed to create ${debugName} instance: ${(err && err.stack) || + err}` + ); + setError(err, target, anchor); + } else { + throw err; + } + } + } + } +} + +const copyStatics = (component, proxy) => { + //forward static properties and methods + for (let key in component) { + proxy[key] = component[key]; + } +}; + +/* +creates a proxy object that +decorates the original component with trackers +and ensures resolution to the +latest version of the component +*/ +export function createProxy(Adapter, id, component, hotOptions) { + const debugName = getDebugName(id); + const instances = []; + + // current object will be updated, proxy instances will keep a ref + const current = { + component, + hotOptions, + }; + + 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) { + super( + { + Adapter, + id, + debugName, + current, + register: rerender => { + instances.push(rerender); + }, + unregister: () => { + const i = instances.indexOf(this); + instances.splice(i, 1); + }, + }, + options + ); + } + }, + }[name]; + + // reload all existing instances of this component + const reload = ({ component, hotOptions }) => { + // update current references + Object.assign(current, { component, hotOptions }); + + // TODO delete props/methods previously added and of which value has + // not changed since + copyStatics(component, proxy); + + const errors = []; + + instances.forEach(rerender => { + try { + rerender(); + } catch (err) { + errors.push(err); + } + }); + + if (errors.length > 0) { + return false; + } + + return true; + }; + + return { id, proxy, reload }; +} diff --git a/lib/svelte3/hot/svelte-hooks.js b/lib/svelte3/hot/svelte-hooks.js new file mode 100644 index 00000000..a479809b --- /dev/null +++ b/lib/svelte3/hot/svelte-hooks.js @@ -0,0 +1,73 @@ +/** + * Emulates forthcoming HMR hooks in Svelte. + * + * All references to private compoonent state ($$) are now isolated in this + * module. + */ + +export 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; + return { props, callbacks, bound }; +}; + +export const restoreState = (cmp, restore) => { + if (!restore) { + return; + } + const { callbacks, bound } = restore; + if (callbacks) { + cmp.$$.callbacks = callbacks; + } + if (bound) { + cmp.$$.bound = bound; + } + // props, props.$$slots are restored at component creation (works + // better -- well, at all actually) +}; + +// emulates (forthcoming?) svelte dev hooks +// +// 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 +// +// NOTE onMount must provide target & anchor (for us to be able to determinate +// actual DOM insertion point) +// +// TODO should copyComponentMethods be done here? +// +export const hookComponent = (proxy, cmp, { onMount, onDestroy }) => { + if (onMount) { + const m = cmp.$$.fragment.m; + cmp.$$.fragment.m = (...args) => { + const result = m(...args); + onMount(...args); + return result; + }; + } + + if (onDestroy) { + cmp.$$.on_destroy.push(onDestroy); + } + + // 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) + proxy.$$ = cmp.$$; +}; diff --git a/lib/svelte3/make-hot.js b/lib/svelte3/make-hot.js new file mode 100644 index 00000000..2767a0f6 --- /dev/null +++ b/lib/svelte3/make-hot.js @@ -0,0 +1,31 @@ +const posixify = require('../posixify'); + +const hotApi = require.resolve('./hot/hot-api.js'); + +const nativeAdapter = require.resolve('../svelte-native/hot/proxy-adapter-native.js'); + +const quote = JSON.stringify; + +function makeHot(id, code, hotOptions = {}) { + const options = JSON.stringify(hotOptions); + + // NOTE Native adapter cannot be required in code (as opposed to this + // generated code) because it requires modules from NativeScript's code that + // are not resolvable for non-native users (and those missing modules would + // prevent webpack from building). + const adapter = hotOptions.native + ? `require('${posixify(nativeAdapter)}').default` + : 'undefined'; + + const replacement = ` + if (module.hot) { + const { applyHMR } = require('${posixify(hotApi)}'); + $2 = applyHMR(${options}, ${quote(id)}, module.hot, $2, ${adapter}); + } + export default $2; + `; + + return code.replace(/(export default ([^;]*));/, replacement); +} + +module.exports = makeHot; diff --git a/package-lock.json b/package-lock.json index 56dab1ca..86e50a8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1243,12 +1243,6 @@ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true }, - "svelte": { - "version": "3.0.0-beta.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.0.0-beta.5.tgz", - "integrity": "sha512-uPDvJ37NGCdIi/Nk2RvtYcrNuVOO34SA+T4ZDJMyb5JiRQ4xHXtbBpxl6ytzftHewfQqKGcEeB+T3LVLIwj6OQ==", - "dev": true - }, "svelte-dev-helper": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/svelte-dev-helper/-/svelte-dev-helper-1.1.9.tgz", diff --git a/package.json b/package.json index eb043f8a..6ea3b99a 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,7 @@ "eslint-plugin-mocha": "^5.2.0", "mocha": "^5.2.0", "sinon": "^6.1.5", - "sinon-chai": "^3.2.0", - "svelte": "^3.0.0-beta.5" + "sinon-chai": "^3.2.0" }, "peerDependencies": { "svelte": ">1.44.0"