diff --git a/.eslintrc b/.eslintrc index 162c2a89a..11ef9fe40 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,12 +15,31 @@ env: browser: true node: true mocha: true + jest: true + +overrides: + - files: ["**/*.ts", "**/*.tsx"] + parser: "@typescript-eslint/parser" + parserOptions: + ecmaVersion: 2018 + sourceType: module + project: "./tsconfig.json" + extends: + - eslint:recommended + - plugin:@typescript-eslint/eslint-recommended + - plugin:@typescript-eslint/recommended + plugins: + - "@typescript-eslint" + rules: + "@typescript-eslint/no-namespace": 0 rules: no-console: 0 function-paren-newline: 0 object-curly-newline: 0 + # https://stackoverflow.com/a/59268871/5241481 + import/extensions: ['error', 'ignorePackages', {"js": 'never',"ts": "never"}] # https://github.com/benmosher/eslint-plugin-import/issues/340 import/no-extraneous-dependencies: 0 @@ -28,3 +47,6 @@ rules: settings: import/core-modules: - react-redux + import/resolver: + node: + extensions: [".js", ".ts", ".d.ts"] diff --git a/.travis.yml b/.travis.yml index 41dbd27d4..8f97b1257 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,7 +35,7 @@ before_install: install: - travis_retry gem install bundler -v '>2' - - travis_retry nvm install 10.13.0 + - travis_retry nvm install 13.9.0 - node -v - travis_retry npm i -g yarn - travis_retry bundle install diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..ee6fa2d66 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'ts-jest/presets/js-with-ts', + testEnvironment: 'jsdom', +}; diff --git a/node_package/.babelrc b/node_package/.babelrc deleted file mode 100644 index 006c5f080..000000000 --- a/node_package/.babelrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "presets": [ - ["@babel/preset-env", {"useBuiltIns": "entry"}], - "@babel/preset-react" - ], - "plugins": [ - "@babel/plugin-transform-flow-strip-types", - ["@babel/plugin-transform-runtime", {"corejs": 2}] - ] -} diff --git a/node_package/.flowconfig b/node_package/.flowconfig deleted file mode 100644 index ef6f4c16d..000000000 --- a/node_package/.flowconfig +++ /dev/null @@ -1,3 +0,0 @@ -[ignore] -.*/lib/.* -.*/node_modules/.* diff --git a/node_package/src/Authenticity.js b/node_package/src/Authenticity.ts similarity index 52% rename from node_package/src/Authenticity.js rename to node_package/src/Authenticity.ts index 6d26960b0..ec1dea86a 100644 --- a/node_package/src/Authenticity.js +++ b/node_package/src/Authenticity.ts @@ -1,16 +1,15 @@ -// @flow +import type { AuthenticityHeaders } from './types/index'; export default { - - authenticityToken() { - const token: ?HTMLElement = document.querySelector('meta[name="csrf-token"]'); + authenticityToken(): string | null { + const token = document.querySelector('meta[name="csrf-token"]'); if (token && (token instanceof window.HTMLMetaElement)) { return token.content; } return null; }, - authenticityHeaders(otherHeaders: {[id:string]: string} = {}) { + authenticityHeaders(otherHeaders: {[id: string]: string} = {}): AuthenticityHeaders { return Object.assign(otherHeaders, { 'X-CSRF-Token': this.authenticityToken(), 'X-Requested-With': 'XMLHttpRequest', diff --git a/node_package/src/ComponentRegistry.js b/node_package/src/ComponentRegistry.ts similarity index 73% rename from node_package/src/ComponentRegistry.js rename to node_package/src/ComponentRegistry.ts index 4426d5691..0b00137e6 100644 --- a/node_package/src/ComponentRegistry.js +++ b/node_package/src/ComponentRegistry.ts @@ -1,5 +1,4 @@ -// key = name used by react_on_rails -// value = { name, component, generatorFunction: boolean, isRenderer: boolean } +import type { RegisteredComponent, ComponentOrRenderFunction, RenderFunction } from './types/index'; import generatorFunction from './generatorFunction'; const registeredComponents = new Map(); @@ -8,7 +7,7 @@ export default { /** * @param components { component1: component1, component2: component2, etc. } */ - register(components) { + register(components: { [id: string]: ComponentOrRenderFunction }): void { Object.keys(components).forEach(name => { if (registeredComponents.has(name)) { console.warn('Called register for component that is already registered', name); @@ -20,7 +19,7 @@ export default { } const isGeneratorFunction = generatorFunction(component); - const isRenderer = isGeneratorFunction && component.length === 3; + const isRenderer = isGeneratorFunction && (component as RenderFunction).length === 3; registeredComponents.set(name, { name, @@ -33,9 +32,9 @@ export default { /** * @param name - * @returns { name, component, generatorFunction } + * @returns { name, component, generatorFunction, isRenderer } */ - get(name) { + get(name: string): RegisteredComponent { if (registeredComponents.has(name)) { return registeredComponents.get(name); } @@ -48,9 +47,9 @@ Registered component names include [ ${keys} ]. Maybe you forgot to register the /** * Get a Map containing all registered components. Useful for debugging. * @returns Map where key is the component name and values are the - * { name, component, generatorFunction} + * { name, component, generatorFunction, isRenderer} */ - components() { + components(): Map { return registeredComponents; }, }; diff --git a/node_package/src/ReactOnRails.js b/node_package/src/ReactOnRails.ts similarity index 78% rename from node_package/src/ReactOnRails.js rename to node_package/src/ReactOnRails.ts index 2784b37b6..fffbbd2ad 100644 --- a/node_package/src/ReactOnRails.js +++ b/node_package/src/ReactOnRails.ts @@ -1,4 +1,6 @@ import ReactDOM from 'react-dom'; +import type { ReactElement, Component } from 'react'; +import type { Store } from 'redux'; import * as ClientStartup from './clientStartup'; import handleError from './handleError'; @@ -9,20 +11,33 @@ import buildConsoleReplay from './buildConsoleReplay'; import createReactElement from './createReactElement'; import Authenticity from './Authenticity'; import context from './context'; +import type { + RegisteredComponent, + RenderParams, + ErrorOptions, + ComponentOrRenderFunction, + AuthenticityHeaders, + StoreGenerator +} from './types/index'; const ctx = context(); +if (ctx === undefined) { + throw new Error("The context (usually Window or NodeJS's Global) is undefined."); +} + const DEFAULT_OPTIONS = { traceTurbolinks: false, }; ctx.ReactOnRails = { + options: {}, /** * Main entry point to using the react-on-rails npm package. This is how Rails will be able to * find you components for rendering. * @param components (key is component name, value is component) */ - register(components) { + register(components: { [id: string]: ComponentOrRenderFunction }): void { ComponentRegistry.register(components); }, @@ -32,7 +47,7 @@ ctx.ReactOnRails = { * the setStore API is different in that it's the actual store hydrated with props. * @param stores (keys are store names, values are the store generators) */ - registerStore(stores) { + registerStore(stores: { [id: string]: Store }): void { if (!stores) { throw new Error('Called ReactOnRails.registerStores with a null or undefined, rather than ' + 'an Object with keys being the store names and the values are the store generators.'); @@ -50,7 +65,7 @@ ctx.ReactOnRails = { * there is no store with the given name. * @returns Redux Store, possibly hydrated */ - getStore(name, throwIfMissing = true) { + getStore(name: string, throwIfMissing = true): Store | undefined { return StoreRegistry.getStore(name, throwIfMissing); }, @@ -59,7 +74,7 @@ ctx.ReactOnRails = { * Available Options: * `traceTurbolinks: true|false Gives you debugging messages on Turbolinks events */ - setOptions(newOptions) { + setOptions(newOptions: {traceTurbolinks: boolean}): void { if ('traceTurbolinks' in newOptions) { this.options.traceTurbolinks = newOptions.traceTurbolinks; @@ -69,7 +84,7 @@ ctx.ReactOnRails = { if (Object.keys(newOptions).length > 0) { throw new Error( - 'Invalid options passed to ReactOnRails.options: ', JSON.stringify(newOptions), + `Invalid options passed to ReactOnRails.options: ${JSON.stringify(newOptions)}`, ); } }, @@ -80,7 +95,7 @@ ctx.ReactOnRails = { * More details can be found here: * https://github.com/shakacode/react_on_rails/blob/master/docs/additional-reading/turbolinks.md */ - reactOnRailsPageLoaded() { + reactOnRailsPageLoaded(): void { ClientStartup.reactOnRailsPageLoaded(); }, @@ -89,7 +104,7 @@ ctx.ReactOnRails = { * @returns String or null */ - authenticityToken() { + authenticityToken(): string | null { return Authenticity.authenticityToken(); }, @@ -99,7 +114,7 @@ ctx.ReactOnRails = { * @returns {*} header */ - authenticityHeaders(otherHeaders = {}) { + authenticityHeaders(otherHeaders: { [id: string]: string } = {}): AuthenticityHeaders { return Authenticity.authenticityHeaders(otherHeaders); }, @@ -112,7 +127,7 @@ ctx.ReactOnRails = { * @param key * @returns option value */ - option(key) { + option(key: string): string | number | boolean | undefined { return this.options[key]; }, @@ -122,7 +137,7 @@ ctx.ReactOnRails = { * @param name * @returns Redux Store generator function */ - getStoreGenerator(name) { + getStoreGenerator(name: string): StoreGenerator { return StoreRegistry.getStoreGenerator(name); }, @@ -131,7 +146,7 @@ ctx.ReactOnRails = { * @param name * @returns Redux Store, possibly hydrated */ - setStore(name, store) { + setStore(name: string, store: Store): void { return StoreRegistry.setStore(name, store); }, @@ -139,7 +154,7 @@ ctx.ReactOnRails = { * Clears hydratedStores to avoid accidental usage of wrong store hydrated in previous/parallel * request. */ - clearHydratedStores() { + clearHydratedStores(): void { StoreRegistry.clearHydratedStores(); }, @@ -156,13 +171,13 @@ ctx.ReactOnRails = { * @param hydrate Pass truthy to update server rendered html. Default is falsy * @returns {virtualDomElement} Reference to your component's backing instance */ - render(name, props, domNodeId, hydrate) { + render(name: string, props: Record, domNodeId: string, hydrate: boolean): void | Element | Component { const componentObj = ComponentRegistry.get(name); const reactElement = createReactElement({ componentObj, props, domNodeId }); const render = hydrate ? ReactDOM.hydrate : ReactDOM.render; // eslint-disable-next-line react/no-render-return-value - return render(reactElement, document.getElementById(domNodeId)); + return render(reactElement as ReactElement, document.getElementById(domNodeId)); }, /** @@ -170,7 +185,7 @@ ctx.ReactOnRails = { * @param name * @returns {name, component, generatorFunction, isRenderer} */ - getComponent(name) { + getComponent(name: string): RegisteredComponent { return ComponentRegistry.get(name); }, @@ -178,7 +193,7 @@ ctx.ReactOnRails = { * Used by server rendering by Rails * @param options */ - serverRenderReactComponent(options) { + serverRenderReactComponent(options: RenderParams): string { return serverRenderReactComponent(options); }, @@ -186,14 +201,14 @@ ctx.ReactOnRails = { * Used by Rails to catch errors in rendering * @param options */ - handleError(options) { + handleError(options: ErrorOptions): string | undefined { return handleError(options); }, /** * Used by Rails server rendering to replay console messages. */ - buildConsoleReplay() { + buildConsoleReplay(): string { return buildConsoleReplay(); }, @@ -201,7 +216,7 @@ ctx.ReactOnRails = { * Get an Object containing all registered components. Useful for debugging. * @returns {*} */ - registeredComponents() { + registeredComponents(): Map { return ComponentRegistry.components(); }, @@ -209,7 +224,7 @@ ctx.ReactOnRails = { * Get an Object containing all registered store generators. Useful for debugging. * @returns {*} */ - storeGenerators() { + storeGenerators(): Map { return StoreRegistry.storeGenerators(); }, @@ -217,11 +232,11 @@ ctx.ReactOnRails = { * Get an Object containing all hydrated stores. Useful for debugging. * @returns {*} */ - stores() { + stores(): Map { return StoreRegistry.stores(); }, - resetOptions() { + resetOptions(): void { this.options = Object.assign({}, DEFAULT_OPTIONS); }, }; diff --git a/node_package/src/RenderUtils.js b/node_package/src/RenderUtils.ts similarity index 67% rename from node_package/src/RenderUtils.js rename to node_package/src/RenderUtils.ts index 70c13b7a2..b240bcfe1 100644 --- a/node_package/src/RenderUtils.js +++ b/node_package/src/RenderUtils.ts @@ -1,5 +1,5 @@ export default { - wrapInScriptTags(scriptId, scriptBody) { + wrapInScriptTags(scriptId: string, scriptBody: string): string { if (!scriptBody) { return ''; } diff --git a/node_package/src/StoreRegistry.js b/node_package/src/StoreRegistry.ts similarity index 88% rename from node_package/src/StoreRegistry.js rename to node_package/src/StoreRegistry.ts index 5fd33214c..6881ab0d3 100644 --- a/node_package/src/StoreRegistry.js +++ b/node_package/src/StoreRegistry.ts @@ -1,5 +1,6 @@ -// key = name used by react_on_rails to identify the store -// value = redux store creator, which is a function that takes props and returns a store +import type { Store } from 'redux'; +import type { StoreGenerator } from './types'; + const registeredStoreGenerators = new Map(); const hydratedStores = new Map(); @@ -8,7 +9,7 @@ export default { * Register a store generator, a function that takes props and returns a store. * @param storeGenerators { name1: storeGenerator1, name2: storeGenerator2 } */ - register(storeGenerators) { + register(storeGenerators: { [id: string]: Store }): void { Object.keys(storeGenerators).forEach(name => { if (registeredStoreGenerators.has(name)) { console.warn('Called registerStore for store that is already registered', name); @@ -31,7 +32,7 @@ export default { * there is no store with the given name. * @returns Redux Store, possibly hydrated */ - getStore(name, throwIfMissing = true) { + getStore(name: string, throwIfMissing = true): Store | undefined { if (hydratedStores.has(name)) { return hydratedStores.get(name); } @@ -62,7 +63,7 @@ This can happen if you are server rendering and either: * @param name * @returns storeCreator with given name */ - getStoreGenerator(name) { + getStoreGenerator(name: string): StoreGenerator { if (registeredStoreGenerators.has(name)) { return registeredStoreGenerators.get(name); } @@ -77,14 +78,14 @@ This can happen if you are server rendering and either: * @param name * @param store (not the storeGenerator, but the hydrated store) */ - setStore(name, store) { + setStore(name: string, store: Store): void { hydratedStores.set(name, store); }, /** * Internally used function to completely clear hydratedStores Map. */ - clearHydratedStores() { + clearHydratedStores(): void { hydratedStores.clear(); }, @@ -92,7 +93,7 @@ This can happen if you are server rendering and either: * Get a Map containing all registered store generators. Useful for debugging. * @returns Map where key is the component name and values are the store generators. */ - storeGenerators() { + storeGenerators(): Map { return registeredStoreGenerators; }, @@ -100,7 +101,7 @@ This can happen if you are server rendering and either: * Get a Map containing all hydrated stores. Useful for debugging. * @returns Map where key is the component name and values are the hydrated stores. */ - stores() { + stores(): Map { return hydratedStores; }, }; diff --git a/node_package/src/buildConsoleReplay.js b/node_package/src/buildConsoleReplay.ts similarity index 70% rename from node_package/src/buildConsoleReplay.js rename to node_package/src/buildConsoleReplay.ts index f82c2bebc..dd6e4f589 100644 --- a/node_package/src/buildConsoleReplay.js +++ b/node_package/src/buildConsoleReplay.ts @@ -1,9 +1,15 @@ -// @flow - import RenderUtils from './RenderUtils'; import scriptSanitizedVal from './scriptSanitizedVal'; -export function consoleReplay() { +declare global { + interface Console { + history?: { + arguments: Array>; level: "error" | "log" | "debug"; + }[]; + } +} + +export function consoleReplay(): string { // console.history is a global polyfill used in server rendering. // $FlowFixMe if (!(console.history instanceof Array)) { @@ -19,7 +25,7 @@ export function consoleReplay() { val = `${e.message}: ${arg}`; } - return scriptSanitizedVal(val); + return scriptSanitizedVal(val as string); }); return `console.${msg.level}.apply(console, ${JSON.stringify(stringifiedList)});`; @@ -28,6 +34,6 @@ export function consoleReplay() { return lines.join('\n'); } -export default function buildConsoleReplay() { +export default function buildConsoleReplay(): string { return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay()); } diff --git a/node_package/src/clientStartup.js b/node_package/src/clientStartup.ts similarity index 65% rename from node_package/src/clientStartup.js rename to node_package/src/clientStartup.ts index b0fe53b4a..a7a34d718 100644 --- a/node_package/src/clientStartup.js +++ b/node_package/src/clientStartup.ts @@ -1,13 +1,37 @@ -/* global ReactOnRails Turbolinks */ - import ReactDOM from 'react-dom'; +import type { ReactElement } from 'react'; +import type { + ReactOnRails as ReactOnRailsType, + RailsContext, + RegisteredComponent, + RenderFunction +} from './types/index'; import createReactElement from './createReactElement'; import isRouterResult from './isCreateReactElementResultNonReactComponent'; +declare global { + interface Window { + ReactOnRails: ReactOnRailsType; + __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; + } + namespace NodeJS { + interface Global { + ReactOnRails: ReactOnRailsType; + } + } + namespace Turbolinks { + interface TurbolinksStatic { + controller?: {}; + } + } +} + +declare const ReactOnRails: ReactOnRailsType; + const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; -function findContext() { +function findContext(): Window | NodeJS.Global { if (typeof window.ReactOnRails !== 'undefined') { return window; } else if (typeof ReactOnRails !== 'undefined') { @@ -19,61 +43,67 @@ ReactOnRails is undefined in both global and window namespaces. `); } -function debugTurbolinks(...msg) { +function debugTurbolinks(...msg: string[]): void { if (!window) { return; } const context = findContext(); - if (context.ReactOnRails.option('traceTurbolinks')) { + if (context.ReactOnRails && context.ReactOnRails.option('traceTurbolinks')) { console.log('TURBO:', ...msg); } } -function turbolinksInstalled() { +function turbolinksInstalled(): boolean { return (typeof Turbolinks !== 'undefined'); } -function forEach(fn, className, railsContext) { +function forEach(fn: (element: Element, railsContext: RailsContext) => void, className: string, railsContext: RailsContext): void { const els = document.getElementsByClassName(className); for (let i = 0; i < els.length; i += 1) { fn(els[i], railsContext); } } -function forEachByAttribute(fn, attributeName, railsContext) { +function forEachByAttribute(fn: (element: Element, railsContext: RailsContext) => void, attributeName: string, railsContext: RailsContext): void { const els = document.querySelectorAll(`[${attributeName}]`); for (let i = 0; i < els.length; i += 1) { fn(els[i], railsContext); } } -function forEachComponent(fn, railsContext) { +function forEachComponent(fn: (element: Element, railsContext: RailsContext) => void, railsContext: RailsContext = {}): void { forEach(fn, 'js-react-on-rails-component', railsContext); } -function initializeStore(el, railsContext) { +function initializeStore(el: Element, railsContext: RailsContext): void { const context = findContext(); - const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE); - const props = JSON.parse(el.textContent); + const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ""; + const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; const storeGenerator = context.ReactOnRails.getStoreGenerator(name); const store = storeGenerator(props, railsContext); context.ReactOnRails.setStore(name, store); } -function forEachStore(railsContext) { +function forEachStore(railsContext: RailsContext): void { forEachByAttribute(initializeStore, REACT_ON_RAILS_STORE_ATTRIBUTE, railsContext); } -function turbolinksVersion5() { +function turbolinksVersion5(): boolean { return (typeof Turbolinks.controller !== 'undefined'); } -function turbolinksSupported() { +function turbolinksSupported(): boolean { return Turbolinks.supported; } -function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace) { +function delegateToRenderer( + componentObj: RegisteredComponent, + props: Record, + railsContext: RailsContext, + domNodeId: string, + trace: boolean +): boolean { const { name, component, isRenderer } = componentObj; if (isRenderer) { @@ -83,15 +113,15 @@ DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, ra props, railsContext); } - component(props, railsContext, domNodeId); + (component as RenderFunction)(props, railsContext, domNodeId); return true; } return false; } -function domNodeIdForEl(el) { - return el.getAttribute('data-dom-id'); +function domNodeIdForEl(el: Element): string { + return el.getAttribute('data-dom-id') || ""; } /** @@ -99,13 +129,13 @@ function domNodeIdForEl(el) { * delegates to a renderer registered by the user. * @param el */ -function render(el, railsContext) { +function render(el: Element, railsContext: RailsContext): void { const context = findContext(); // This must match lib/react_on_rails/helper.rb - const name = el.getAttribute('data-component-name'); + const name = el.getAttribute('data-component-name') || ""; const domNodeId = domNodeIdForEl(el); - const props = JSON.parse(el.textContent); - const trace = el.getAttribute('data-trace'); + const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; + const trace = el.getAttribute('data-trace') === "true"; try { const domNode = document.getElementById(domNodeId); @@ -132,9 +162,9 @@ function render(el, railsContext) { You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)} You should return a React.Component always for the client side entry point.`); } else if (shouldHydrate) { - ReactDOM.hydrate(reactElementOrRouterResult, domNode); + ReactDOM.hydrate(reactElementOrRouterResult as ReactElement, domNode); } else { - ReactDOM.render(reactElementOrRouterResult, domNode); + ReactDOM.render(reactElementOrRouterResult as ReactElement, domNode); } } } catch (e) { @@ -144,16 +174,15 @@ You should return a React.Component always for the client side entry point.`); } } -function parseRailsContext() { +function parseRailsContext(): RailsContext { const el = document.getElementById('js-react-on-rails-context'); if (el) { - return JSON.parse(el.textContent); + return (el.textContent !== null) ? JSON.parse(el.textContent) : {}; } - - return null; + return {}; } -export function reactOnRailsPageLoaded() { +export function reactOnRailsPageLoaded(): void { debugTurbolinks('reactOnRailsPageLoaded'); const railsContext = parseRailsContext(); @@ -161,9 +190,10 @@ export function reactOnRailsPageLoaded() { forEachComponent(render, railsContext); } -function unmount(el) { +function unmount(el: Element): void { const domNodeId = domNodeIdForEl(el); const domNode = document.getElementById(domNodeId); + if(domNode === null){return;} try { ReactDOM.unmountComponentAtNode(domNode); } catch (e) { @@ -172,12 +202,12 @@ function unmount(el) { } } -function reactOnRailsPageUnloaded() { +function reactOnRailsPageUnloaded(): void { debugTurbolinks('reactOnRailsPageUnloaded'); forEachComponent(unmount); } -function renderInit() { +function renderInit(): void { // Install listeners when running on the client (browser). // We must do this check for turbolinks AFTER the document is loaded because we load the // Webpack bundles first. @@ -203,13 +233,16 @@ function renderInit() { } } -export function clientStartup(context) { - const { document } = context; +function isWindow (context: Window | NodeJS.Global): context is Window { + return (context as Window).document !== undefined; +} +export function clientStartup(context: Window | NodeJS.Global): void { // Check if server rendering - if (!document) { + if (!isWindow(context)) { return; } + const { document } = context; // Tried with a file local variable, but the install handler gets called twice. // eslint-disable-next-line no-underscore-dangle diff --git a/node_package/src/context.js b/node_package/src/context.ts similarity index 73% rename from node_package/src/context.js rename to node_package/src/context.ts index 1bc612e6b..81b0569f5 100644 --- a/node_package/src/context.js +++ b/node_package/src/context.ts @@ -1,10 +1,8 @@ -// @flow - /** * Get the context, be it window or global * @returns {boolean|Window|*|context} */ -export default function context() { +export default function context(this: void): Window | NodeJS.Global | void { return ((typeof window !== 'undefined') && window) || ((typeof global !== 'undefined') && global) || this; diff --git a/node_package/src/createReactElement.js b/node_package/src/createReactElement.ts similarity index 77% rename from node_package/src/createReactElement.js rename to node_package/src/createReactElement.ts index e4620eada..fe641c4b8 100644 --- a/node_package/src/createReactElement.js +++ b/node_package/src/createReactElement.ts @@ -1,6 +1,7 @@ /* eslint-disable react/prop-types */ import React from 'react'; +import type { CreateParams, ComponentVariant, RenderFunction, CREReturnTypes } from './types/index'; /** * Logic to either call the generatorFunction or call React.createElement to get the @@ -11,7 +12,7 @@ import React from 'react'; * @param options.domNodeId * @param options.trace * @param options.location - * @returns {Element} + * @returns {ReactElement} */ export default function createReactElement({ componentObj, @@ -20,7 +21,7 @@ export default function createReactElement({ domNodeId, trace, shouldHydrate, -}) { +}: CreateParams): CREReturnTypes { const { name, component, generatorFunction } = componentObj; if (trace) { @@ -36,8 +37,8 @@ export default function createReactElement({ } if (generatorFunction) { - return component(props, railsContext); + return (component as RenderFunction)(props, railsContext); } - return React.createElement(component, props); + return React.createElement(component as ComponentVariant, props); } diff --git a/node_package/src/generatorFunction.js b/node_package/src/generatorFunction.ts similarity index 76% rename from node_package/src/generatorFunction.js rename to node_package/src/generatorFunction.ts index 11c134d23..ff7b4d41b 100644 --- a/node_package/src/generatorFunction.js +++ b/node_package/src/generatorFunction.ts @@ -1,7 +1,6 @@ -// @flow - // See discussion: // https://discuss.reactjs.org/t/how-to-determine-if-js-object-is-react-component/2825/2 +import { ComponentOrRenderFunction } from './types/index'; /** * Used to determine we'll call be calling React.createElement on the component of if this is a @@ -9,7 +8,7 @@ * @param component * @returns {boolean} */ -export default function generatorFunction(component: any) { +export default function generatorFunction(component: ComponentOrRenderFunction): boolean { if (!component.prototype) { return false; } diff --git a/node_package/src/handleError.js b/node_package/src/handleError.ts similarity index 89% rename from node_package/src/handleError.js rename to node_package/src/handleError.ts index ab9fddb8d..74e9dd29f 100644 --- a/node_package/src/handleError.js +++ b/node_package/src/handleError.ts @@ -1,7 +1,8 @@ import React from 'react'; import ReactDOMServer from 'react-dom/server'; +import type { ErrorOptions } from './types/index'; -function handleGeneratorFunctionIssue(options) { +function handleGeneratorFunctionIssue(options: {e: Error; name?: string}): string { const { e, name } = options; let msg = ''; @@ -35,7 +36,7 @@ component '${name}' is not a generator function.\n${lastLine}`; return msg; } -const handleError = (options) => { +const handleError = (options: ErrorOptions): string => { const { e, jsCode, serverSide } = options; console.error('Exception in rendering!'); @@ -64,7 +65,7 @@ ${e.stack}`; return ReactDOMServer.renderToString(reactElement); } - return undefined; + return "undefined"; }; export default handleError; diff --git a/node_package/src/isCreateReactElementResultNonReactComponent.js b/node_package/src/isCreateReactElementResultNonReactComponent.js deleted file mode 100644 index c5ea39cf9..000000000 --- a/node_package/src/isCreateReactElementResultNonReactComponent.js +++ /dev/null @@ -1,6 +0,0 @@ -export default function isResultNonReactComponent(reactElementOrRouterResult) { - return !!( - reactElementOrRouterResult.renderedHtml || - reactElementOrRouterResult.redirectLocation || - reactElementOrRouterResult.error); -} diff --git a/node_package/src/isCreateReactElementResultNonReactComponent.ts b/node_package/src/isCreateReactElementResultNonReactComponent.ts new file mode 100644 index 000000000..006f621fa --- /dev/null +++ b/node_package/src/isCreateReactElementResultNonReactComponent.ts @@ -0,0 +1,9 @@ +import type { CREReturnTypes } from './types/index'; + +export default function isResultNonReactComponent(reactElementOrRouterResult: CREReturnTypes): boolean { + return !!( + (reactElementOrRouterResult as {renderedHtml: string}).renderedHtml || + (reactElementOrRouterResult as {redirectLocation: {pathname: string; search: string}}).redirectLocation || + (reactElementOrRouterResult as {routeError: Error}).routeError || + (reactElementOrRouterResult as {error: Error}).error); +} diff --git a/node_package/src/scriptSanitizedVal.js b/node_package/src/scriptSanitizedVal.ts similarity index 68% rename from node_package/src/scriptSanitizedVal.js rename to node_package/src/scriptSanitizedVal.ts index 85f0cfdc6..cca1435de 100644 --- a/node_package/src/scriptSanitizedVal.js +++ b/node_package/src/scriptSanitizedVal.ts @@ -1,4 +1,4 @@ -export default (val) => { +export default (val: string): string => { // Replace closing const re = /<\/\W*script/gi; return val.replace(re, '(/script'); diff --git a/node_package/src/serverRenderReactComponent.js b/node_package/src/serverRenderReactComponent.ts similarity index 72% rename from node_package/src/serverRenderReactComponent.js rename to node_package/src/serverRenderReactComponent.ts index db1a6fc2b..06f310b46 100644 --- a/node_package/src/serverRenderReactComponent.js +++ b/node_package/src/serverRenderReactComponent.ts @@ -1,4 +1,5 @@ import ReactDOMServer from 'react-dom/server'; +import type { ReactElement } from 'react'; import ComponentRegistry from './ComponentRegistry'; import createReactElement from './createReactElement'; @@ -6,8 +7,9 @@ import isCreateReactElementResultNonReactComponent from './isCreateReactElementResultNonReactComponent'; import buildConsoleReplay from './buildConsoleReplay'; import handleError from './handleError'; +import type { RenderParams } from './types/index'; -export default function serverRenderReactComponent(options) { +export default function serverRenderReactComponent(options: RenderParams): string { const { name, domNodeId, trace, props, railsContext } = options; let htmlResult = ''; @@ -32,17 +34,17 @@ See https://github.com/shakacode/react_on_rails#renderer-functions`); if (isCreateReactElementResultNonReactComponent(reactElementOrRouterResult)) { // We let the client side handle any redirect // Set hasErrors in case we want to throw a Rails exception - hasErrors = !!reactElementOrRouterResult.routeError; + hasErrors = !!(reactElementOrRouterResult as {routeError: Error}).routeError; if (hasErrors) { console.error( - `React Router ERROR: ${JSON.stringify(reactElementOrRouterResult.routeError)}`, + `React Router ERROR: ${JSON.stringify((reactElementOrRouterResult as {routeError: Error}).routeError)}`, ); } - if (reactElementOrRouterResult.redirectLocation) { + if ((reactElementOrRouterResult as {redirectLocation: {pathname: string; search: string}}).redirectLocation) { if (trace) { - const { redirectLocation } = reactElementOrRouterResult; + const { redirectLocation } = (reactElementOrRouterResult as {redirectLocation: {pathname: string; search: string}}); const redirectPath = redirectLocation.pathname + redirectLocation.search; console.log(`\ ROUTER REDIRECT: ${name} to dom node with id: ${domNodeId}, redirect to ${redirectPath}`, @@ -51,10 +53,10 @@ ROUTER REDIRECT: ${name} to dom node with id: ${domNodeId}, redirect to ${redire // For redirects on server rendering, we can't stop Rails from returning the same result. // Possibly, someday, we could have the rails server redirect. } else { - htmlResult = reactElementOrRouterResult.renderedHtml; + htmlResult = (reactElementOrRouterResult as { renderedHtml: string }).renderedHtml; } } else { - htmlResult = ReactDOMServer.renderToString(reactElementOrRouterResult); + htmlResult = ReactDOMServer.renderToString(reactElementOrRouterResult as ReactElement); } } catch (e) { hasErrors = true; diff --git a/node_package/src/types/index.d.ts b/node_package/src/types/index.d.ts new file mode 100644 index 000000000..8abb5abee --- /dev/null +++ b/node_package/src/types/index.d.ts @@ -0,0 +1,103 @@ +import type { ReactElement, Component, FunctionComponent, ComponentClass } from 'react'; +import type { Store } from 'redux'; + +type ComponentVariant = FunctionComponent | ComponentClass; + +interface Params { + props?: {}; + railsContext?: RailsContext; + domNodeId?: string; + trace?: boolean; +} + +export interface RenderParams extends Params { + name: string; +} + +export interface CreateParams extends Params { + componentObj: RegisteredComponent; + shouldHydrate?: boolean; +} + +export interface RailsContext { + railsEnv?: "development" | "test" | "staging" | "production"; + inMailer?: boolean; + i18nLocale?: string; + i18nDefaultLocale?: string; + rorVersion?: string; + rorPro?: boolean; + serverSide?: boolean; + originalUrl?: string; + href?: string; + location?: string; + scheme?: string; + host?: string; + port?: string; + pathname?: string; + search?: string; + httpAcceptLanguage?: string; +} + +type RenderFunction = (props?: {}, railsContext?: RailsContext, domNodeId?: string) => ReactElement; + +type ComponentOrRenderFunction = ComponentVariant | RenderFunction; + +type AuthenticityHeaders = {[id: string]: string} & {'X-CSRF-Token': string | null; 'X-Requested-With': string}; + +type StoreGenerator = (props: {}, railsContext: RailsContext) => Store + +type CREReturnTypes = {renderedHtml: string} | {redirectLocation: {pathname: string; search: string}} | {routeError: Error} | {error: Error} | ReactElement; + +export type { // eslint-disable-line import/prefer-default-export + ComponentOrRenderFunction, + ComponentVariant, + AuthenticityHeaders, + RenderFunction, + StoreGenerator, + CREReturnTypes +} + +export interface RegisteredComponent { + name: string; + component: ComponentOrRenderFunction; + generatorFunction: boolean; + isRenderer: boolean; +} + +interface FileError extends Error { + fileName: string; + lineNumber: string; +} + +export interface ErrorOptions { + e: FileError; + name?: string; + jsCode?: string; + serverSide: boolean; +} + +export interface ReactOnRails { + register(components: { [id: string]: ComponentOrRenderFunction }): void; + registerStore(stores: { [id: string]: Store }): void; + getStore(name: string, throwIfMissing: boolean): Store | undefined; + setOptions(newOptions: {traceTurbolinks: boolean}): void; + reactOnRailsPageLoaded(): void; + authenticityToken(): string | null; + authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; + option(key: string): string | number | boolean | undefined; + getStoreGenerator(name: string): Function; + setStore(name: string, store: Store): void; + clearHydratedStores(): void; + render( + name: string, props: Record, domNodeId: string, hydrate: boolean + ): void | Element | Component; + getComponent(name: string): RegisteredComponent; + serverRenderReactComponent(options: RenderParams): string; + handleError(options: ErrorOptions): string | undefined; + buildConsoleReplay(): string; + registeredComponents(): Map; + storeGenerators(): Map; + stores(): Map; + resetOptions(): void; + options: Record; +} diff --git a/node_package/tests/Authenticity.test.js b/node_package/tests/Authenticity.test.js index bac24308e..b0e28566e 100644 --- a/node_package/tests/Authenticity.test.js +++ b/node_package/tests/Authenticity.test.js @@ -1,30 +1,36 @@ -import test from 'tape'; import ReactOnRails from '../src/ReactOnRails'; -test('authenticityToken and authenticityHeaders', (assert) => { - assert.plan(4); - - assert.ok(typeof ReactOnRails.authenticityToken === 'function', - 'authenticityToken function exists in ReactOnRails API'); - - assert.ok(typeof ReactOnRails.authenticityHeaders === 'function', - 'authenticityHeaders function exists in ReactOnRails API'); - - const testToken = 'TEST_CSRF_TOKEN'; - - const meta = document.createElement('meta'); - meta.name = 'csrf-token'; - meta.content = testToken; - document.head.appendChild(meta); - - const realToken = ReactOnRails.authenticityToken(); - - assert.equal(realToken, testToken, - 'authenticityToken can read Rails CSRF token from '); - - const realHeader = ReactOnRails.authenticityHeaders(); - - assert.deepEqual(realHeader, { 'X-CSRF-Token': testToken, 'X-Requested-With': 'XMLHttpRequest' }, - 'authenticityHeaders returns valid header with CSRF token', - ); -}); +const testToken = 'TEST_CSRF_TOKEN'; + +const meta = document.createElement('meta'); +meta.name = 'csrf-token'; +meta.content = testToken; +document.head.appendChild(meta); + +describe('authenticityToken', () => { + expect.assertions(2); + it('exists in ReactOnRails API', () => { + expect.assertions(1); + expect(typeof ReactOnRails.authenticityToken).toEqual('function'); + }) + + it('can read Rails CSRF token from ', () => { + expect.assertions(1); + const realToken = ReactOnRails.authenticityToken(); + expect(realToken).toEqual(testToken); + }) +}) + +describe('authenticityHeaders', () => { + expect.assertions(2); + it('exists in ReactOnRails API', () => { + expect.assertions(1); + expect(typeof ReactOnRails.authenticityHeaders).toEqual('function'); + }) + + it('returns valid header with CSRF token', () => { + expect.assertions(1); + const realHeader = ReactOnRails.authenticityHeaders(); + expect(realHeader).toEqual({ 'X-CSRF-Token': testToken, 'X-Requested-With': 'XMLHttpRequest' }); + }) +}) diff --git a/node_package/tests/ComponentRegistry.test.js b/node_package/tests/ComponentRegistry.test.js index 323d3762b..e6022541b 100644 --- a/node_package/tests/ComponentRegistry.test.js +++ b/node_package/tests/ComponentRegistry.test.js @@ -5,106 +5,102 @@ /* eslint-disable no-unused-vars */ /* eslint-disable import/extensions */ -import test from 'tape'; import React from 'react'; import createReactClass from 'create-react-class'; import ComponentRegistry from '../src/ComponentRegistry'; -test('ComponentRegistry registers and retrieves generator function components', (assert) => { - assert.plan(1); - const C1 = () =>
HELLO
; - ComponentRegistry.register({ C1 }); - const actual = ComponentRegistry.get('C1'); - const expected = { name: 'C1', component: C1, generatorFunction: true, isRenderer: false }; - assert.deepEqual(actual, expected, - 'ComponentRegistry should store and retrieve a generator function'); -}); +describe('ComponentRegistry', () => { + expect.assertions(9); + it('registers and retrieves generator function components', () => { + expect.assertions(1); + const C1 = () =>
HELLO
; + ComponentRegistry.register({ C1 }); + const actual = ComponentRegistry.get('C1'); + const expected = { name: 'C1', component: C1, generatorFunction: true, isRenderer: false }; + expect(actual).toEqual(expected); + }); -test('ComponentRegistry registers and retrieves ES5 class components', (assert) => { - assert.plan(1); - const C2 = createReactClass({ - render() { - return
WORLD
; - }, + it('registers and retrieves ES5 class components', () => { + expect.assertions(1); + const C2 = createReactClass({ + render() { + return
WORLD
; + }, + }); + ComponentRegistry.register({ C2 }); + const actual = ComponentRegistry.get('C2'); + const expected = { name: 'C2', component: C2, generatorFunction: false, isRenderer: false }; + expect(actual).toEqual(expected); }); - ComponentRegistry.register({ C2 }); - const actual = ComponentRegistry.get('C2'); - const expected = { name: 'C2', component: C2, generatorFunction: false, isRenderer: false }; - assert.deepEqual(actual, expected, - 'ComponentRegistry should store and retrieve a ES5 class'); -}); -test('ComponentRegistry registers and retrieves ES6 class components', (assert) => { - assert.plan(1); - class C3 extends React.Component { - render() { - return ( -
Wow!
- ); + it('registers and retrieves ES6 class components', () => { + expect.assertions(1); + class C3 extends React.Component { + render() { + return ( +
Wow!
+ ); + } } - } - ComponentRegistry.register({ C3 }); - const actual = ComponentRegistry.get('C3'); - const expected = { name: 'C3', component: C3, generatorFunction: false, isRenderer: false }; - assert.deepEqual(actual, expected, - 'ComponentRegistry should store and retrieve a ES6 class'); -}); + ComponentRegistry.register({ C3 }); + const actual = ComponentRegistry.get('C3'); + const expected = { name: 'C3', component: C3, generatorFunction: false, isRenderer: false }; + expect(actual).toEqual(expected); + }); -test('ComponentRegistry registers and retrieves renderers', (assert) => { - assert.plan(1); - const C4 = (a1, a2, a3) => null; - ComponentRegistry.register({ C4 }); - const actual = ComponentRegistry.get('C4'); - const expected = { name: 'C4', component: C4, generatorFunction: true, isRenderer: true }; - assert.deepEqual(actual, expected, - 'ComponentRegistry registers and retrieves renderers'); -}); + it('registers and retrieves renderers', () => { + expect.assertions(1); + const C4 = (a1, a2, a3) => null; + ComponentRegistry.register({ C4 }); + const actual = ComponentRegistry.get('C4'); + const expected = { name: 'C4', component: C4, generatorFunction: true, isRenderer: true }; + expect(actual).toEqual(expected); + }); -/* - * NOTE: Since ComponentRegistry is a singleton, it preserves value as the tests run. - * Thus, tests are cummulative. - */ -test('ComponentRegistry registers and retrieves multiple components', (assert) => { - assert.plan(3); - const C5 = () =>
WHY
; - const C6 = () =>
NOW
; - ComponentRegistry.register({ C5 }); - ComponentRegistry.register({ C6 }); - const components = ComponentRegistry.components(); - assert.equal(components.size, 6, 'size should be 6'); - assert.deepEqual(components.get('C5'), - { name: 'C5', component: C5, generatorFunction: true, isRenderer: false }); - assert.deepEqual(components.get('C6'), - { name: 'C6', component: C6, generatorFunction: true, isRenderer: false }); -}); + /* + * NOTE: Since is a singleton, it preserves value as the tests run. + * Thus, tests are cummulative. + */ + it('registers and retrieves multiple components', () => { + expect.assertions(3); + const C5 = () =>
WHY
; + const C6 = () =>
NOW
; + ComponentRegistry.register({ C5 }); + ComponentRegistry.register({ C6 }); + const components = ComponentRegistry.components(); + expect(components.size).toBe(6); + expect(components.get('C5')).toEqual( + { name: 'C5', component: C5, generatorFunction: true, isRenderer: false }); + expect(components.get('C6')).toEqual( + { name: 'C6', component: C6, generatorFunction: true, isRenderer: false }); + }); -test('ComponentRegistry only detects a renderer function if it has three arguments', (assert) => { - assert.plan(2); - const C7 = (a1, a2) => null; - const C8 = (a1) => null; - ComponentRegistry.register({ C7 }); - ComponentRegistry.register({ C8 }); - const components = ComponentRegistry.components(); - assert.deepEqual(components.get('C7'), - { name: 'C7', component: C7, generatorFunction: true, isRenderer: false }); - assert.deepEqual(components.get('C8'), - { name: 'C8', component: C8, generatorFunction: true, isRenderer: false }); -}); + it('only detects a renderer function if it has three arguments', () => { + expect.assertions(2); + const C7 = (a1, a2) => null; + const C8 = (a1) => null; + ComponentRegistry.register({ C7 }); + ComponentRegistry.register({ C8 }); + const components = ComponentRegistry.components(); + expect(components.get('C7')).toEqual( + { name: 'C7', component: C7, generatorFunction: true, isRenderer: false }); + expect(components.get('C8')).toEqual( + { name: 'C8', component: C8, generatorFunction: true, isRenderer: false }); + }); -test('ComponentRegistry throws error for retrieving unregistered component', (assert) => { - assert.plan(1); - assert.throws(() => ComponentRegistry.get('foobar'), - /Could not find component registered with name foobar/, - 'Expected an exception for calling ComponentRegistry.get with an invalid name.', - ); -}); + it('throws error for retrieving unregistered component', () => { + expect.assertions(1); + expect(() => ComponentRegistry.get('foobar')).toThrow( + /Could not find component registered with name foobar/ + ); + }); -test('ComponentRegistry throws error for setting null component', (assert) => { - assert.plan(1); - const C9 = null; - assert.throws(() => ComponentRegistry.register({ C9 }), - /Called register with null component named C9/, - 'Expected an exception for calling ComponentRegistry.set with a null component.', - ); -}); + it('throws error for setting null component', () => { + expect.assertions(1); + const C9 = null; + expect(() => ComponentRegistry.register({ C9 })).toThrow( + /Called register with null component named C9/ + ); + }) +}) diff --git a/node_package/tests/ReactOnRails.test.js b/node_package/tests/ReactOnRails.test.js index 282ac552b..148ce9320 100644 --- a/node_package/tests/ReactOnRails.test.js +++ b/node_package/tests/ReactOnRails.test.js @@ -3,145 +3,137 @@ /* eslint-disable react/prefer-stateless-function */ /* eslint-disable react/jsx-filename-extension */ -import test from 'tape'; import { createStore } from 'redux'; import React from 'react'; import createReactClass from 'create-react-class'; import ReactOnRails from '../src/ReactOnRails'; -test('ReactOnRails render returns a virtual DOM element for component', (assert) => { - assert.plan(1); - const R1 = createReactClass({ - render() { - return ( -
WORLD
- ); - }, +describe('ReactOnRails', () => { + expect.assertions(14); + it('render returns a virtual DOM element for component', () => { + expect.assertions(1); + const R1 = createReactClass({ + render() { + return ( +
WORLD
+ ); + }, + }); + ReactOnRails.register({ R1 }); + + document.body.innerHTML = '
'; + // eslint-disable-next-line no-underscore-dangle + const actual = ReactOnRails.render('R1', {}, 'root')._reactInternalFiber.type; + expect(actual).toEqual(R1); }); - ReactOnRails.register({ R1 }); - - // eslint-disable-next-line no-underscore-dangle - const actual = ReactOnRails.render('R1', {}, 'root')._reactInternalFiber.type; - assert.deepEqual(actual, R1, - 'ReactOnRails render should return a virtual DOM element for component'); -}); - -test('ReactOnRails accepts traceTurbolinks as an option true', (assert) => { - ReactOnRails.resetOptions(); - assert.plan(1); - ReactOnRails.setOptions({ traceTurbolinks: true }); - const actual = ReactOnRails.option('traceTurbolinks'); - assert.equal(actual, true); -}); - -test('ReactOnRails accepts traceTurbolinks as an option false', (assert) => { - ReactOnRails.resetOptions(); - assert.plan(1); - ReactOnRails.setOptions({ traceTurbolinks: false }); - const actual = ReactOnRails.option('traceTurbolinks'); - assert.equal(actual, false); -}); - -test('ReactOnRails not specified has traceTurbolinks as false', (assert) => { - ReactOnRails.resetOptions(); - assert.plan(1); - ReactOnRails.setOptions({ }); - const actual = ReactOnRails.option('traceTurbolinks'); - assert.equal(actual, false); -}); - -test('serverRenderReactComponent throws error for invalid options', (assert) => { - ReactOnRails.resetOptions(); - assert.plan(1); - assert.throws( - () => ReactOnRails.setOptions({ foobar: true }), - /Invalid option/, - 'setOptions should throw an error for invalid options', - ); -}); - -test('registerStore throws if passed a falsey object (null, undefined, etc)', (assert) => { - assert.plan(3); - - assert.throws( - () => ReactOnRails.registerStore(null), - /null or undefined/, - 'registerStore should throw an error if a falsey value is passed (null)', - ); - - assert.throws( - () => ReactOnRails.registerStore(undefined), - /null or undefined/, - 'registerStore should throw an error if a falsey value is passed (undefined)', - ); - - assert.throws( - () => ReactOnRails.registerStore(false), - /null or undefined/, - 'registerStore should throw an error if a falsey value is passed (false)', - ); -}); - -test('register store and getStoreGenerator allow registration', (assert) => { - assert.plan(2); - function reducer() { - return {}; - } - - function storeGenerator(props) { - return createStore(reducer, props); - } - - ReactOnRails.registerStore({ storeGenerator }); - - const actual = ReactOnRails.getStoreGenerator('storeGenerator'); - assert.equal(actual, storeGenerator, - `Could not find 'storeGenerator' amongst store generators \ -${JSON.stringify(ReactOnRails.storeGenerators())}.`); - - assert.deepEqual(ReactOnRails.storeGenerators(), new Map([['storeGenerator', storeGenerator]])); -}); - -test('setStore and getStore', (assert) => { - assert.plan(2); - function reducer() { - return {}; - } - - function storeGenerator(props) { - return createStore(reducer, props); - } - - const store = storeGenerator({}); - - ReactOnRails.setStore('storeGenerator', store); - - const actual = ReactOnRails.getStore('storeGenerator'); - assert.equal(actual, store, - `Could not find 'store' amongst store generators ${JSON.stringify(ReactOnRails.stores())}.`); - const expected = new Map(); - expected.set('storeGenerator', store); - - assert.deepEqual(ReactOnRails.stores(), expected); -}); - -test('clearHydratedStores', (assert) => { - assert.plan(2); - function reducer() { - return {}; - } - - function storeGenerator(props) { - return createStore(reducer, props); - } - - ReactOnRails.setStore('storeGenerator', storeGenerator({})); - const actual = new Map(); - actual.set(storeGenerator); - assert.deepEqual(actual, ReactOnRails.stores()); - - ReactOnRails.clearHydratedStores(); - const expected = new Map(); - assert.deepEqual(ReactOnRails.stores(), expected, - 'clearHydratedStores should clear hydratedStores map'); -}); + + it('accepts traceTurbolinks as an option true', () => { + ReactOnRails.resetOptions(); + expect.assertions(1); + ReactOnRails.setOptions({ traceTurbolinks: true }); + const actual = ReactOnRails.option('traceTurbolinks'); + expect(actual).toBe(true); + }); + + it('accepts traceTurbolinks as an option false', () => { + ReactOnRails.resetOptions(); + expect.assertions(1); + ReactOnRails.setOptions({ traceTurbolinks: false }); + const actual = ReactOnRails.option('traceTurbolinks'); + expect(actual).toBe(false); + }); + + it('not specified has traceTurbolinks as false', () => { + ReactOnRails.resetOptions(); + expect.assertions(1); + ReactOnRails.setOptions({ }); + const actual = ReactOnRails.option('traceTurbolinks'); + expect(actual).toBe(false); + }); + + it('setOptions method throws error for invalid options', () => { + ReactOnRails.resetOptions(); + expect.assertions(1); + expect( + () => ReactOnRails.setOptions({ foobar: true }) + ).toThrow(/Invalid option/); + }); + + it('registerStore throws if passed a falsey object (null, undefined, etc)', () => { + expect.assertions(3); + + expect( + () => ReactOnRails.registerStore(null) + ).toThrow(/null or undefined/); + + expect( + () => ReactOnRails.registerStore(undefined) + ).toThrow(/null or undefined/); + + expect( + () => ReactOnRails.registerStore(false) + ).toThrow(/null or undefined/); + }); + + it('register store and getStoreGenerator allow registration', () => { + expect.assertions(2); + function reducer() { + return {}; + } + + function storeGenerator(props) { + return createStore(reducer, props); + } + + ReactOnRails.registerStore({ storeGenerator }); + + const actual = ReactOnRails.getStoreGenerator('storeGenerator'); + expect(actual).toEqual(storeGenerator) + + expect(ReactOnRails.storeGenerators()).toEqual(new Map([['storeGenerator', storeGenerator]])); + }); + + it('setStore and getStore', () => { + expect.assertions(2); + function reducer() { + return {}; + } + + function storeGenerator(props) { + return createStore(reducer, props); + } + + const store = storeGenerator({}); + + ReactOnRails.setStore('storeGenerator', store); + + const actual = ReactOnRails.getStore('storeGenerator'); + expect(actual).toEqual(store); + const expected = new Map(); + expected.set('storeGenerator', store); + + expect(ReactOnRails.stores()).toEqual(expected); + }); + + it('clearHydratedStores', () => { + expect.assertions(2); + function reducer() { + return {}; + } + + function storeGenerator(props) { + return createStore(reducer, props); + } + + + const result = storeGenerator({}); + ReactOnRails.setStore('storeGenerator', result); + const actual = new Map(); + actual.set('storeGenerator', result); + expect(actual).toEqual(ReactOnRails.stores()); + + ReactOnRails.clearHydratedStores(); + const expected = new Map(); + expect(ReactOnRails.stores()).toEqual(expected); + }) +}) diff --git a/node_package/tests/StoreRegistry.test.js b/node_package/tests/StoreRegistry.test.js index b260de2f7..c6df48518 100644 --- a/node_package/tests/StoreRegistry.test.js +++ b/node_package/tests/StoreRegistry.test.js @@ -1,4 +1,3 @@ -import test from 'tape'; import { createStore } from 'redux'; import StoreRegistry from '../src/StoreRegistry'; @@ -15,89 +14,84 @@ function storeGenerator2(props) { return createStore(reducer, props); } -test('StoreRegistry throws error for registering null or undefined store', (assert) => { - assert.plan(2); - StoreRegistry.stores().clear(); - assert.throws(() => StoreRegistry.register({ storeGenerator: null }), - /Called ReactOnRails.registerStores with a null or undefined as a value/, - 'Expected an exception for calling StoreRegistry.register with an invalid store generator.', - ); - assert.throws(() => StoreRegistry.register({ storeGenerator: undefined }), - /Called ReactOnRails.registerStores with a null or undefined as a value/, - 'Expected an exception for calling StoreRegistry.register with an invalid store generator.', - ); -}); +describe('', () => { + expect.assertions(11); + it('StoreRegistry throws error for registering null or undefined store', () => { + expect.assertions(2); + StoreRegistry.stores().clear(); + expect(() => StoreRegistry.register({ storeGenerator: null })).toThrow( + /Called ReactOnRails.registerStores with a null or undefined as a value/ + ); + expect(() => StoreRegistry.register({ storeGenerator: undefined })).toThrow( + /Called ReactOnRails.registerStores with a null or undefined as a value/, + ); + }); -test('StoreRegistry throws error for retrieving unregistered store', (assert) => { - assert.plan(1); - StoreRegistry.stores().clear(); - assert.throws(() => StoreRegistry.getStore('foobar'), - /There are no stores hydrated and you are requesting the store/, - 'Expected an exception for calling StoreRegistry.getStore with no registered stores.', - ); -}); + it('StoreRegistry throws error for retrieving unregistered store', () => { + expect.assertions(1); + StoreRegistry.stores().clear(); + expect(() => StoreRegistry.getStore('foobar')).toThrow( + /There are no stores hydrated and you are requesting the store/, + ); + }); -test('StoreRegistry registers and retrieves generator function stores', (assert) => { - assert.plan(2); - StoreRegistry.register({ storeGenerator, storeGenerator2 }); - const actual = StoreRegistry.getStoreGenerator('storeGenerator'); - const expected = storeGenerator; - assert.deepEqual(actual, expected, - 'StoreRegistry should store and retrieve the storeGenerator'); - const actual2 = StoreRegistry.getStoreGenerator('storeGenerator2'); - const expected2 = storeGenerator2; - assert.deepEqual(actual2, expected2, - 'StoreRegistry should store and retrieve the storeGenerator2'); -}); + it('StoreRegistry registers and retrieves generator function stores', () => { + expect.assertions(2); + StoreRegistry.register({ storeGenerator, storeGenerator2 }); + const actual = StoreRegistry.getStoreGenerator('storeGenerator'); + const expected = storeGenerator; + expect(actual).toEqual(expected); + const actual2 = StoreRegistry.getStoreGenerator('storeGenerator2'); + const expected2 = storeGenerator2; + expect(actual2).toEqual(expected2); + }); -test('StoreRegistry throws error for retrieving unregistered store', (assert) => { - assert.plan(1); - assert.throws(() => StoreRegistry.getStoreGenerator('foobar'), - /Could not find store registered with name 'foobar'\. Registered store names include/, - 'Expected an exception for calling StoreRegistry.getStoreGenerator with an invalid name.', - ); -}); + it('StoreRegistry throws error for retrieving unregistered store', () => { + expect.assertions(1); + expect(() => StoreRegistry.getStoreGenerator('foobar')).toThrow( + /Could not find store registered with name 'foobar'\. Registered store names include/, + ); + }); -test('StoreRegistry returns undefined for retrieving unregistered store, ' + - 'passing throwIfMissing = false', -(assert) => { - assert.plan(1); - StoreRegistry.setStore('foobarX', {}); - const actual = StoreRegistry.getStore('foobar', false); - const expected = undefined; - assert.equals(actual, expected, 'StoreRegistry.get should return undefined for missing ' + - 'store if throwIfMissing is passed as false', + it('StoreRegistry returns undefined for retrieving unregistered store, ' + + 'passing throwIfMissing = false', + () => { + expect.assertions(1); + StoreRegistry.setStore('foobarX', {}); + const actual = StoreRegistry.getStore('foobar', false); + const expected = undefined; + expect(actual).toEqual(expected); + }, ); -}, -); -test('StoreRegistry getStore, setStore', (assert) => { - assert.plan(1); - const store = storeGenerator({}); - StoreRegistry.setStore('storeGenerator', store); - const actual = StoreRegistry.getStore('storeGenerator'); - const expected = store; - assert.deepEqual(actual, expected, 'StoreRegistry should store and retrieve the store'); -}); + it('StoreRegistry getStore, setStore', () => { + expect.assertions(1); + const store = storeGenerator({}); + StoreRegistry.setStore('storeGenerator', store); + const actual = StoreRegistry.getStore('storeGenerator'); + const expected = store; + expect(actual).toEqual(expected); + }); -test('StoreRegistry throws error for retrieving unregistered hydrated store', (assert) => { - assert.plan(1); - assert.throws(() => StoreRegistry.getStore('foobar'), - /Could not find hydrated store with name 'foobar'\. Hydrated store names include/, - 'Expected an exception for calling StoreRegistry.getStore with an invalid name.', - ); -}); + it('StoreRegistry throws error for retrieving unregistered hydrated store', () => { + expect.assertions(1); + expect(() => StoreRegistry.getStore('foobar')).toThrow( + /Could not find hydrated store with name 'foobar'\. Hydrated store names include/, + ); + }); -test('StoreRegistry clearHydratedStores', (assert) => { - assert.plan(2); - StoreRegistry.stores().clear(); + it('StoreRegistry clearHydratedStores', () => { + expect.assertions(2); + StoreRegistry.stores().clear(); - StoreRegistry.setStore('storeGenerator', storeGenerator({})); - const actual = new Map(); - actual.set(storeGenerator); - assert.deepEqual(actual, StoreRegistry.stores()); + const result = storeGenerator({}); + StoreRegistry.setStore('storeGenerator', result); + const actual = new Map(); + actual.set('storeGenerator', result); + expect(actual).toEqual(StoreRegistry.stores()); - StoreRegistry.clearHydratedStores(); - const expected = new Map(); - assert.deepEqual(StoreRegistry.stores(), expected); -}); + StoreRegistry.clearHydratedStores(); + const expected = new Map(); + expect(StoreRegistry.stores()).toEqual(expected); + }) +}) diff --git a/node_package/tests/buildConsoleReplay.test.js b/node_package/tests/buildConsoleReplay.test.js index fa75363b1..31e97dc5b 100644 --- a/node_package/tests/buildConsoleReplay.test.js +++ b/node_package/tests/buildConsoleReplay.test.js @@ -1,97 +1,93 @@ -import test from 'tape'; - import buildConsoleReplay, { consoleReplay } from '../src/buildConsoleReplay'; -test('consoleReplay does not crash if no console.history object', (assert) => { - assert.plan(1); - assert.doesNotThrow(() => consoleReplay(), /Error/, - 'consoleReplay should not throw an exception if no console.history object'); -}); +describe('consoleReplay', () => { + expect.assertions(8); + it('does not throw an exception if no console.history object', () => { + expect.assertions(1); + expect(() => consoleReplay()).not.toThrow(/Error/); + }); -test('consoleReplay returns empty string if no console.history object', (assert) => { - assert.plan(1); - const actual = consoleReplay(); - const expected = ''; - assert.equals(actual, expected, - 'consoleReplay should return an empty string if no console.history'); -}); + it('returns empty string if no console.history object', () => { + expect.assertions(1); + const actual = consoleReplay(); + const expected = ''; + expect(actual).toEqual(expected); + }); -test('consoleReplay does not crash if no console.history.length is 0', (assert) => { - assert.plan(1); - console.history = []; - assert.doesNotThrow(() => consoleReplay(), /Error/, - 'consoleReplay should not throw an exception if console.history.length is zero'); -}); + it('does not throw an exception if console.history.length is zero', () => { + expect.assertions(1); + console.history = []; + expect(() => consoleReplay()).not.toThrow(/Error/); + }); -test('consoleReplay returns empty string if no console.history object', (assert) => { - assert.plan(1); - console.history = []; - const actual = consoleReplay(); - const expected = ''; - assert.equals(actual, expected, - 'consoleReplay should return an empty string if console.history is an empty array'); -}); + it('returns empty string if no console.history object', () => { + expect.assertions(1); + console.history = []; + const actual = consoleReplay(); + const expected = ''; + expect(actual).toEqual(expected); + }); -test('consoleReplay replays multiple history messages', (assert) => { - assert.plan(1); - console.history = [ - { arguments: ['a', 'b'], level: 'log' }, - { arguments: ['c', 'd'], level: 'warn' }, - ]; - const actual = consoleReplay(); - const expected = - 'console.log.apply(console, ["a","b"]);\nconsole.warn.apply(console, ["c","d"]);'; - assert.equals(actual, expected, - 'Unexpected value for console replay history'); -}); + it('replays multiple history messages', () => { + expect.assertions(1); + console.history = [ + { arguments: ['a', 'b'], level: 'log' }, + { arguments: ['c', 'd'], level: 'warn' }, + ]; + const actual = consoleReplay(); + const expected = + 'console.log.apply(console, ["a","b"]);\nconsole.warn.apply(console, ["c","d"]);'; + expect(actual).toEqual(expected); + }); -test('consoleReplay replays converts console param objects to JSON', (assert) => { - assert.plan(1); - console.history = [ - { arguments: ['some message', { a: 1, b: 2 }], level: 'log' }, - { arguments: ['other message', { c: 3, d: 4 }], level: 'warn' }, - ]; - const actual = consoleReplay(); + it('replays converts console param objects to JSON', () => { + expect.assertions(1); + console.history = [ + { arguments: ['some message', { a: 1, b: 2 }], level: 'log' }, + { arguments: ['other message', { c: 3, d: 4 }], level: 'warn' }, + ]; + const actual = consoleReplay(); - const expected = `console.log.apply(console, ["some message","{\\"a\\":1,\\"b\\":2}"]); + const expected = `console.log.apply(console, ["some message","{\\"a\\":1,\\"b\\":2}"]); console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]);`; - assert.equals(actual, expected, 'Unexpected value for console replay history'); -}); + expect(actual).toEqual(expected); + }); -test('consoleReplay replays converts script tag inside of object string to be safe ', (assert) => { - assert.plan(1); - console.history = [ - { - arguments: [ - 'some message ', - { a: 'Wow', b: 2 }, - ], - level: 'log', - }, - { arguments: ['other message', { c: 3, d: 4 }], level: 'warn' }, - ]; - const actual = consoleReplay(); + it('replays converts script tag inside of object string to be safe ', () => { + expect.assertions(1); + console.history = [ + { + arguments: [ + 'some message ', + { a: 'Wow', b: 2 }, + ], + level: 'log', + }, + { arguments: ['other message', { c: 3, d: 4 }], level: 'warn' }, + ]; + const actual = consoleReplay(); - const expected = `console.log.apply(console, ["some message (/script>`; - assert.equals(actual, expected, 'Unexpected value for console replay history'); -}); + expect(actual).toEqual(expected); + }) +}) diff --git a/node_package/tests/generatorFunction.test.js b/node_package/tests/generatorFunction.test.js index 80147b463..1671e98f8 100644 --- a/node_package/tests/generatorFunction.test.js +++ b/node_package/tests/generatorFunction.test.js @@ -3,91 +3,87 @@ /* eslint-disable react/prefer-stateless-function */ /* eslint-disable react/jsx-filename-extension */ -import test from 'tape'; import React from 'react'; import createReactClass from 'create-react-class'; import generatorFunction from '../src/generatorFunction'; -test('generatorFunction: ES5 Component recognized as React.Component', (assert) => { - assert.plan(1); +describe('generatorFunction', () => { + expect.assertions(6); + it('returns false for a ES5 React Component', () => { + expect.assertions(1); - const es5Component = createReactClass({ - render() { - return (
ES5 React Component
); - }, - }); + const es5Component = createReactClass({ + render() { + return (
ES5 React Component
); + }, + }); - assert.equal(generatorFunction(es5Component), false, - 'ES5 Component should not be a generatorFunction'); -}); + expect(generatorFunction(es5Component)).toBe(false); + }); -test('generatorFunction: ES6 class recognized as React.Component', (assert) => { - assert.plan(1); + it('returns false for a ES6 React class', () => { + expect.assertions(1); - class ES6Component extends React.Component { - render() { - return (
ES6 Component
); + class ES6Component extends React.Component { + render() { + return (
ES6 Component
); + } } - } - assert.equal(generatorFunction(ES6Component), false, - 'es6Component should not be a generatorFunction'); -}); + expect(generatorFunction(ES6Component)).toBe(false); + }); -test('generatorFunction: ES6 class subclass recognized as React.Component', (assert) => { - assert.plan(1); + it('returns false for a ES6 React subclass', () => { + expect.assertions(1); - class ES6Component extends React.Component { - render() { - return (
ES6 Component
); + class ES6Component extends React.Component { + render() { + return (
ES6 Component
); + } } - } - class ES6ComponentChild extends ES6Component { - render() { - return (
ES6 Component Child
); + class ES6ComponentChild extends ES6Component { + render() { + return (
ES6 Component Child
); + } } - } - - assert.equal(generatorFunction(ES6ComponentChild), false, - 'es6ComponentChild should not be a generatorFunction'); -}); -test('generatorFunction: pure component recognized as React.Component', (assert) => { - assert.plan(1); - - /* eslint-disable react/prop-types */ - const pureComponent = (props) =>

{ props.title }

; - /* eslint-enable react/prop-types */ + expect(generatorFunction(ES6ComponentChild)).toBe(false); + }); - assert.equal(generatorFunction(pureComponent), true, - 'pure component should not be a generatorFunction'); -}); + it('returns true for a stateless functional component', () => { + expect.assertions(1); -test('generatorFunction: Generator function recognized as such', (assert) => { - assert.plan(1); + /* eslint-disable react/prop-types */ + const pureComponent = (props) =>

{ props.title }

; + /* eslint-enable react/prop-types */ - const foobarComponent = createReactClass({ - render() { - return (
Component for Generator Function
); - }, + expect(generatorFunction(pureComponent)).toBe(true); }); - const foobarGeneratorFunction = () => foobarComponent; + it('returns true for a generator function', () => { + expect.assertions(1); - assert.equal(generatorFunction(foobarGeneratorFunction), true, - 'generatorFunction should be recognized as a generatorFunction'); -}); + const foobarComponent = createReactClass({ + render() { + return (
Component for Generator Function
); + }, + }); -test('generatorFunction: simple object returns false', (assert) => { - assert.plan(1); + const foobarGeneratorFunction = () => foobarComponent; + + expect(generatorFunction(foobarGeneratorFunction)).toBe(true); + }); - const foobarComponent = { - hello() { - return 'world'; - }, - }; - assert.equal(generatorFunction(foobarComponent), false, - 'Plain object is not a generator function.'); -}); + it('returns false for simple object', () => { + expect.assertions(1); + + const foobarComponent = { + hello() { + return 'world'; + }, + }; + expect(generatorFunction(foobarComponent)).toBe(false); + }) +}) diff --git a/node_package/tests/helpers/test_helper.js b/node_package/tests/helpers/test_helper.js deleted file mode 100644 index 3764968af..000000000 --- a/node_package/tests/helpers/test_helper.js +++ /dev/null @@ -1,12 +0,0 @@ -import { JSDOM } from 'jsdom'; - -const url = 'http://localhost'; -const { document } = (new JSDOM('
', { url })).window; -global.document = document; -global.window = document.defaultView; - -Object.keys(window).forEach((key) => { - if (!(key in global)) { - global[key] = window[key]; - } -}); diff --git a/node_package/tests/scriptSanitizedVal.test.js b/node_package/tests/scriptSanitizedVal.test.js index 6dabcdccf..45a0ff153 100644 --- a/node_package/tests/scriptSanitizedVal.test.js +++ b/node_package/tests/scriptSanitizedVal.test.js @@ -1,48 +1,44 @@ -import test from 'tape'; - import scriptSanitizedVal from '../src/scriptSanitizedVal'; -test('scriptSanitizedVal returns no { - assert.plan(1); - const input = '[SERVER] This is a script:"" { + expect.assertions(1); + const input = '[SERVER] This is a script:"" 2', (assert) => { - assert.plan(1); - const input = 'Script2:"" '; - const actual = scriptSanitizedVal(input); - const expected = 'Script2:""(/script xx> 2', () => { + expect.assertions(1); + const input = 'Script2:"" '; + const actual = scriptSanitizedVal(input); + const expected = 'Script2:""(/script xx> 3', (assert) => { - assert.plan(1); - const input = 'Script3:"" '; - const actual = scriptSanitizedVal(input); - const expected = 'Script3:""(/script xx> 3', () => { + expect.assertions(1); + const input = 'Script3:"" '; + const actual = scriptSanitizedVal(input); + const expected = 'Script3:""(/script xx> 4', (assert) => { - assert.plan(1); - const input = 'Script4""alert(\'WTF4\')'; - const actual = scriptSanitizedVal(input); - const expected = 'Script4""(/script 4', () => { + expect.assertions(1); + const input = 'Script4""alert(\'WTF4\')'; + const actual = scriptSanitizedVal(input); + const expected = 'Script4""(/script 5', (assert) => { - assert.plan(1); - const input = 'Script5:"" '; - const actual = scriptSanitizedVal(input); - const expected = 'Script5:""(/script> 5', () => { + expect.assertions(1); + const input = 'Script5:"" '; + const actual = scriptSanitizedVal(input); + const expected = 'Script5:""(/script>