/* global document */ import { adapter as ProxyAdapterDom } from '../proxy-adapter-dom' import { patchShowModal, getModalData } from './patch-page-show-modal' patchShowModal() // 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. // // NOTE The last paragraph has not really been confirmed with NS6. // // 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 // replace (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. // // https://github.com/NativeScript/NativeScript/issues/6283 const getNavTransition = ({ transition }) => { if (typeof transition === 'string') { transition = { name: transition } } return transition ? { animated: true, transition } : { animated: false } } // 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) // // NOTE this "worked" in TNS 5, but not anymore in TNS 6: updated version bellow // // eslint-disable-next-line no-unused-vars const replacePage_tns5 = (frame, newPageElement, hotOptions) => { 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, }, }) } // Updated for TNS v6 // // https://github.com/NativeScript/NativeScript/blob/6.1.1/tns-core-modules/ui/frame/frame-common.ts#L656 const replacePage = (frame, newPageElement) => { const currentBackstackEntry = frame._currentEntry const newPage = newPageElement.nativeView const newBackstackEntry = { entry: currentBackstackEntry.entry, resolvedPage: newPage, navDepth: currentBackstackEntry.navDepth, fragmentTag: currentBackstackEntry.fragmentTag, frameId: currentBackstackEntry.frameId, } const navigationContext = { entry: newBackstackEntry, isBackNavigation: false, navigationType: 2 /* NavigationType replace */, } frame._navigationQueue.push(navigationContext) frame._processNextNavigationEntry() } export const adapter = 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 } } // 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! 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) { if (type === 'navigatedFrom') { this.navigatedFromHandler = handler if (ownOn) { originalNativeView.on = on } else { delete originalNativeView.on } } else { //some other handler wireup, we will just pass it on. if (on) { on(type, handler) } } } } afterMount(target, anchor) { // nativePageElement needs to be updated each time (only for page // components, native component that are not pages follow normal flow) // // TODO 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. // // TODO is it really true that components' elements cannot move in the // DOM? what about keyed list? // const isNativePage = (target.tagName === 'fragment' || target.tagName === 'frame') && target.firstChild && target.firstChild.tagName == 'page' if (isNativePage) { const nativePageElement = target.firstChild this.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) { // NOTE not so sure of bellow with the new NS6 method for replace // // 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 { replacePage(frame, newPageElement, hotOptions) } } 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') // not using conservative for now, since there's nothing in place here to // leverage it (yet?) -- and it might be easier to miss breakages in native // only code paths refreshComponent(target, null) // this.nativePageElement is updated in afterMount, triggered by proxy / hooks 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 } }