├── .github └── workflows │ ├── deploy_docs.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── .tool-versions ├── README.md ├── ci ├── docusaurus ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── More │ │ ├── Dependencies │ │ │ ├── 00-overview.md │ │ │ ├── 01-dependency-removal.md │ │ │ ├── 02-closures.md │ │ │ ├── 03-service-locator.md │ │ │ ├── 04-default-arguments.md │ │ │ ├── 05-class-constructors.md │ │ │ ├── 06-setters.md │ │ │ ├── 07-react-props.md │ │ │ └── 08-react-context.md │ │ ├── You Do Not Need │ │ │ ├── 00-overview.md │ │ │ ├── 01-replace-method-on-existing-object.md │ │ │ ├── 02-file-mocks.md │ │ │ ├── 03-mocking-tools.md │ │ │ ├── 04-fancy-assertions.md │ │ │ └── 05-fancy-test-runner.md │ │ └── why.md │ ├── Testing Components │ │ ├── 00-overview.md │ │ ├── 01-anatomy-of-a-test.md │ │ ├── 02-isolateComponent.md │ │ ├── 03-isolateComponentTree.md │ │ ├── 04-testing-with-react-context.md │ │ ├── 05-testing-effects.md │ │ ├── 06-testing-component-unmount.md │ │ ├── 07-refs.md │ │ ├── 08-portals.md │ │ └── api.md │ ├── Testing Hooks │ │ ├── 01-overview.md │ │ ├── 02-testing-effects.md │ │ ├── 03-testing-context.md │ │ ├── 04-testing-async-hooks.md │ │ └── api.md │ ├── api.md │ ├── compared-to.md │ ├── installation.md │ └── main.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ └── markdown-page.md ├── static │ └── .nojekyll ├── tsconfig.json ├── wip │ ├── 00-welcome.md │ ├── 01-install.md │ ├── 02-quickstart.md │ ├── compare-to.md │ ├── context.md │ ├── effects.md │ ├── inlining.md │ ├── limitations.md │ ├── overview.md │ ├── test-driven-react │ │ ├── 01-tdd.md │ │ ├── 02-not-about-testing-really.md │ │ ├── tactics │ │ │ ├── extract-hook-from-component.md │ │ │ ├── extract-hook-state-from-side-effects.md │ │ │ ├── extract-subcomponent.md │ │ │ ├── inject-dependency.md │ │ │ ├── separate-side-effects-and-logic.md │ │ │ └── use-context-for-composed-ui.md │ │ ├── testing-promises.md │ │ └── testing-time.md │ ├── two-ways-to-test.md │ ├── typescript.md │ └── why.md └── yarn.lock ├── examples ├── CounterButton │ ├── CounterButton.test.tsx │ ├── CounterButton.tsx │ └── README.md ├── README.md ├── ShoppingList │ ├── README.md │ ├── ShoppingList.test.tsx │ └── ShoppingList.tsx ├── jest.config.js ├── package.json ├── tsconfig.json ├── useRememberNames │ ├── README.md │ ├── useRememberNames.test.ts │ └── useRememberNames.ts ├── useTodos │ ├── README.md │ ├── useTodos.test.ts │ └── useTodos.ts └── yarn.lock └── isolate-react ├── README.md ├── isolateHook.md ├── package.json ├── src ├── index.ts ├── isolateComponent │ ├── index.ts │ ├── isolateComponent.ts │ ├── isolatedRenderer │ │ ├── applyProviderContext.ts │ │ ├── componentIsContextProviderForType.ts │ │ ├── index.ts │ │ ├── renderContext.ts │ │ ├── renderMethod.ts │ │ ├── wrapClassComponent.ts │ │ ├── wrapContextConsumer.ts │ │ ├── wrapContextProvider.ts │ │ └── wrapReactMemo.ts │ ├── nodeMatcher.test.tsx │ ├── nodeMatcher.ts │ ├── nodeTree │ │ ├── context.ts │ │ ├── debug.ts │ │ ├── index.ts │ │ ├── inline.ts │ │ ├── nodeTree.test.tsx │ │ ├── nodes │ │ │ ├── common.ts │ │ │ ├── fragmentNode.ts │ │ │ ├── functionNode.ts │ │ │ ├── htmlNode.ts │ │ │ ├── index.ts │ │ │ ├── invalidNode.ts │ │ │ ├── isolatedNode.ts │ │ │ ├── nothingNode.ts │ │ │ ├── portalNode.ts │ │ │ ├── reactNode.ts │ │ │ └── valueNode.ts │ │ ├── parse.ts │ │ ├── reconcile.test.tsx │ │ └── reconcile.ts │ └── types │ │ ├── ComponentInstance.ts │ │ ├── ComponentNode.ts │ │ ├── InputNode.ts │ │ ├── IsolateComponent.ts │ │ ├── IsolatedComponent.ts │ │ ├── NodeContent.ts │ │ ├── QueryableNode.ts │ │ ├── RenderableComponent.ts │ │ ├── Selector.ts │ │ ├── TreeNode.ts │ │ └── index.ts └── isolateHook │ ├── dirtyDependencies.ts │ ├── dispatcher │ ├── effects.ts │ ├── index.ts │ ├── memoize.ts │ ├── useId.ts │ ├── useReducer.ts │ ├── useRef.ts │ ├── useState.ts │ └── useSyncExternalStore.ts │ ├── effectSet.ts │ ├── hookContexts.ts │ ├── index.ts │ ├── isolateHook.ts │ ├── isolatedHookState.test.ts │ ├── isolatedHookState.ts │ ├── types │ ├── IsolateHook.ts │ ├── IsolatedHook.ts │ └── IsolatedHookOptions.ts │ ├── updatableHookStates.test.ts │ └── updatableHookStates.ts ├── test ├── babel-register.js ├── isolateComponent │ ├── children.test.tsx │ ├── class_component.test.tsx │ ├── content.test.tsx │ ├── context_consumer.test.tsx │ ├── disableReactWarnings.ts │ ├── error_boundary.test.tsx │ ├── forward_ref.test.tsx │ ├── getRenderMethod.test.tsx │ ├── hooks.test.tsx │ ├── inline_context.test.tsx │ ├── inline_context_effect.test.tsx │ ├── inline_effect.test.tsx │ ├── inlining.test.tsx │ ├── isolateComponentTree.test.tsx │ ├── isolated_component_queries.test.tsx │ ├── null_element_type.test.tsx │ ├── null_handling.test.tsx │ ├── portal.test.tsx │ ├── react_memo.test.tsx │ ├── react_router_route_matching.test.tsx │ ├── render_function.test.tsx │ ├── setting_context.test.tsx │ ├── update_props.test.tsx │ └── waitForRender.test.tsx └── isolateHook │ ├── bad_parameters.test.ts │ ├── builtIn_hooks.test.ts │ ├── effectSet.test.ts │ ├── effects.test.ts │ ├── hookWithParameters.test.ts │ ├── useCallback.test.ts │ ├── useContext.test.ts │ ├── useDebugValue.test.ts │ ├── useDeferredValue.test.ts │ ├── useId.test.ts │ ├── useImperativeHandle.test.ts │ ├── useMemo.test.ts │ ├── useReducer.test.ts │ ├── useRef.test.ts │ ├── useState.test.ts │ ├── useSyncExternalStore.test.ts │ ├── useTransition.test.ts │ └── waitForUpdate.test.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | deploy: 9 | name: Deploy to GitHub Pages 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: 14.x 17 | 18 | - name: Build docs 19 | run: | 20 | cd docusaurus 21 | yarn install --frozen-lockfile 22 | yarn build 23 | 24 | - name: Deploy to GitHub Pages 25 | uses: peaceiris/actions-gh-pages@v3 26 | with: 27 | github_token: ${{ secrets.GITHUB_TOKEN }} 28 | publish_dir: docusaurus/build 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | on: [push] 3 | 4 | jobs: 5 | ci: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: CI 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: '16' 14 | - run: ./ci 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tmp 3 | coverage 4 | lib 5 | *.log 6 | .nyc_output 7 | .docusaurus 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.13.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # isolate-react 2 | ### [NPM](https://npmjs.com/package/isolate-react) 3 | ### [Documentation](https://davidmfoley.github.io/isolate-react) 4 | 5 | The missing tool for test-driving your react components. 6 | 7 | It's focused on speed and simplicity, has zero dependencies, doesn't require a DOM emulator, and supports any test runner. 8 | 9 | This repository contains: 10 | - [isolate-react](./isolate-react) 11 | - [isolate-react docs](./docusaurus) 12 | - [A set of isolate-react examples](./examples) 13 | 14 | See the [project tracker](https://github.com/davidmfoley/isolate-react/projects/1) for project progress. 15 | 16 | File an [issue](https://github.com/davidmfoley/isolate-react/issues) if you have a suggestion or request. 17 | -------------------------------------------------------------------------------- /ci: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | pushd isolate-react 4 | yarn 5 | yarn ci 6 | popd 7 | 8 | pushd examples 9 | yarn 10 | yarn ci 11 | popd 12 | -------------------------------------------------------------------------------- /docusaurus/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /docusaurus/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | ``` 30 | $ GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docusaurus/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/00-overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dependencies 3 | sidebar_label: Dependencies 4 | --- 5 | 6 | A guide to building software without losing your mind. 7 | 8 | ### This guide is a work-in-progress! 9 | 10 | 11 | ## What is a dependency? 12 | 13 | Often in javascript programs, when module A needs to collaborate with module B, it imports that module and uses its exports directly. 14 | 15 | For example, let's say we have a function that looks up a user from a database: 16 | 17 | ```javascript 18 | // database.js 19 | 20 | const db = connectToDatabase() 21 | 22 | export const findUserByEmail = async (email) => { 23 | const result = await db.query(`select * from users where email = $1`, [email]) 24 | 25 | return result.rows[0] || null 26 | } 27 | ``` 28 | 29 | ... and we want to test another function that uses the database function: 30 | 31 | ```javascript 32 | // api.js 33 | 34 | import { findUserByEmail } from './database' 35 | 36 | export const getUserName = async (email) => { 37 | // get the user record from the database 38 | const user = await findUserByEmail(email) 39 | 40 | // logic we would like to test 41 | if (!user) return "User Not Found" 42 | if (!user.name) return "No Name Provided" 43 | if (user.title) return `${user.title}. ${user.name}` 44 | 45 | return user.name 46 | } 47 | ``` 48 | 49 | We can say that the function `getUserName` *depends on* the function `findUserByEmail`. 50 | 51 | We can further say that this is a "hard" or "static" dependency -- one that is "hard-wired" and not easily replaced. (Javascript as executed in node *does* provide the ability to override the import. However, it's usually an option of last resort and you should almost never need to do that) 52 | 53 | ## How does this affect us? 54 | 55 | ### Hard dependencies make our code harder to understand 56 | 57 | The above example is simple: a function that depends on a single other function. In many applications, the dependencies are much more complex. At runtime, a single request will be serviced by many objects, and each of those objects can have many exposed methods, with only a few of them used. 58 | 59 | This leads to increased *coupling* between objects. 60 | 61 | The techniques used to make our hard dependencies "softer" also tend to have the effect of making their interactions more *explicit*. 62 | 63 | ### Hard dependencies make testing more difficult 64 | 65 | If we want to test the logic in `getUserName` *without* a database, we need a way to give it a fake implementation of `findUserByEmail`, sometimes called a "mock". This can only be accomplished using a "file mock", which is a very complex technique that *increases* the coupling of tests to the implementation. 66 | 67 | ## What can we do? 68 | 69 | "Inverting" or "injecting" a dependency means designing our software so that the dependency (`findUserByEmail`) can be specified outside of its usage (`getUserName`). 70 | 71 | This sounds fancy, and there are a lot of fancy tools that provide "dependency injection". 72 | However, most of the time no additional tools are necessary -- we just need to make some simple changes to the way we design our functions and objects. 73 | 74 | The following pages explain many of the common techniques we can use to invert dependencies in javascript and typescript. Some of the techniques are specific to react, and some are generally applicable to all javascript code. 75 | 76 | ### Notes 77 | 78 | This guide is written with javascript and typescript in mind, in node and the browser. Some of the techniques in here are specific to react and are noted as such. 79 | 80 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/01-dependency-removal.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The best dependency is no dependency 3 | --- 4 | 5 | Before we get into all of the techniques we can use to invert a dependency, let's look at the most powerful technique we have for dealing with unruly dependencies: get rid of them. 6 | 7 | Take our previous example: 8 | 9 | ```javascript 10 | // api.js 11 | 12 | import { findUserByEmail } from './database' 13 | 14 | export const getUserName = async (email) => { 15 | const user = await findUserByEmail(email) 16 | 17 | if (!user) return "User Not Found" 18 | if (!user.name) return "No Name Provided" 19 | if (user.title) return `${user.title}. ${user.name}` 20 | 21 | return user.name 22 | } 23 | ``` 24 | 25 | If we break out the logic we want to test into a separate function, we can then test that function without any "mocks" at all. 26 | 27 | 28 | The result looks like this: 29 | 30 | ```javascript 31 | // api.js 32 | 33 | import { findUserByEmail } from './database' 34 | 35 | export const formatUserName = (user) => { 36 | if (!user) return "User Not Found" 37 | if (!user.name) return "No Name Provided" 38 | if (user.title) return `${user.title}. ${user.name}` 39 | 40 | return user.name 41 | } 42 | 43 | export const getUserName = async (email) => { 44 | const user = await findUserByEmail(email) 45 | 46 | // Test this function directly 47 | return formatUserName(user) 48 | } 49 | ``` 50 | 51 | Now, we can test `formatUserName` directly by passing in different values like `null` or `{ name: 'Arthur'}` and asserting about the result. 52 | 53 | This is one of the most powerful tools in our software design toolkit, and should usually be the first one we reach for. 54 | 55 | ## But, now we aren't testing everything! 56 | 57 | ### How do we test that getUserName correctly calls findUserByEmail!? 58 | 59 | You have a few options: 60 | 61 | - Live with it. In this case, the invocation of findUserByEmail is not the important logic, and is trivial enough that we can assume it is correct. 62 | - Combine this technique with one of the following techniques, and write tests that assert that the expected arguments are passed and that any exceptional cases are properly handled. 63 | 64 | 65 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/02-closures.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Closures 3 | --- 4 | 5 | ## Description 6 | 7 | Create a factory function that takes the services needed and returns a function or object that encloses those services. 8 | 9 | In production code, use the real services to construct the module, by either: 10 | - Creating and exporting the constructed production implementation alongside the factory function 11 | - Calling the factory function in application code to create the production implementation 12 | 13 | In tests, configure the dependencies for testing by calling the factory function with test-friendly values. 14 | 15 | ## Example 16 | 17 | **api.ts** 18 | 19 | ```js 20 | // production api implementation 21 | export const api = { 22 | getWidget: (id) => { ... } 23 | } 24 | ``` 25 | 26 | **widgets.ts** 27 | ```js 28 | import { api } from './api' 29 | 30 | // factory function 31 | export const makeWidgets = (widgetApi) => { 32 | 33 | // return an object that uses the api 34 | return { 35 | getWidgetName: async (widgetId) => { 36 | const widget = await widgetApi.getWidget(widgetId); 37 | if (!widget) return "Unknown Widget" 38 | return widget.name 39 | } 40 | } 41 | } 42 | 43 | // export the production version, this is imported by other modules 44 | export const widgets = makeWidgets(api); 45 | ``` 46 | 47 | **widgets.test.ts** 48 | ```js 49 | import { makeWidgets } from './widgets' 50 | 51 | describe("getWidgetName", () => { 52 | test("unknown widget returns 'Unknown Widget'", () => { 53 | const fakeApi = { 54 | getWidget: async () => undefined 55 | } 56 | 57 | // use the factory to make the object we want to test, with a fake api implementation 58 | const widgets = makeWidgets(fakeApi) 59 | 60 | const name = await widgets.getWidgetName(42) 61 | 62 | expect(name).toEqual('Unknown Widget') 63 | }) 64 | }) 65 | ``` 66 | 67 | ## Notes 68 | 69 | This is a general technique that can be used anywhere, not only in react-aware code. 70 | 71 | This is very similar to using classes that take dependencies as constructor objects. 72 | 73 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/03-service-locator.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Service Locator 3 | --- 4 | 5 | ## Description 6 | 7 | Rather than importing a module(service) directly in the module that consumes it, "locate it" via a function that returns the implementation. 8 | 9 | In production code, configure the locator to return the production implementation. This can be done either by defaulting the locator to the production implementation, or by setting up each service locator when the app starts up. 10 | 11 | In tests, set up the locator to return a different implementation as needed. 12 | 13 | ## Example 14 | 15 | **api.ts** 16 | 17 | ```javascript 18 | // current instance of the service 19 | let _api; 20 | 21 | // Set the service 22 | export const setApi = (api) => { _api = api }; 23 | 24 | // This is the "service locator" 25 | export const getApi = () => _api; 26 | ``` 27 | 28 | **widgets.ts** 29 | ```javascript 30 | import { getApi } from './api' 31 | 32 | export const widgets = { 33 | getWidgetName: async (widgetId) => { 34 | const api = getApi(); 35 | const widget = await api.getWidget(widgetId); 36 | if (!widget) return "Unknown Widget" 37 | return widget.name 38 | } 39 | } 40 | ``` 41 | 42 | **widgets.test.ts** 43 | ```js 44 | import { setApi } from './api' 45 | import { widgets } from './widgets' 46 | 47 | describe("getWidgetName", () => { 48 | test("unknown widget returns 'Unknown Widget'", () => { 49 | // Test "api" that always returns undefined 50 | const fakeApi = { 51 | getWidget: async () => undefined 52 | } 53 | 54 | setApi(fakeApi) 55 | 56 | expect(widgets.getWidgetName(42)).toEqual('Unknown Widget') 57 | }) 58 | }) 59 | ``` 60 | 61 | ## Notes 62 | 63 | This is a general technique that can be used anywhere, not only in react. 64 | 65 | Sometimes many services are grouped together in a single object. This is sometimes called a "Service Registry". 66 | 67 | When we use Service Locator, we add an additional dependency on the locator to each place that uses the service. This complicates the code. 68 | 69 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/04-default-arguments.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Default arguments 3 | --- 4 | 5 | ## Description 6 | 7 | Add services to each function that uses them as arguments. 8 | 9 | In production, use default argument values to bring in the production implementations. 10 | 11 | In tests, pass in test implementations. 12 | 13 | 14 | ## Example 15 | 16 | **api.ts** 17 | 18 | ```javascript 19 | // production api implementation 20 | export const api = { 21 | getWidget: (id) => { ... } 22 | } 23 | ``` 24 | 25 | **widgets.ts** 26 | ```javascript 27 | import { api } from './api' 28 | 29 | export const widgets = { 30 | getWidgetName: async (widgetId, widgetApi = api) => { 31 | const widget = await widgetApi.getWidget(widgetId); 32 | if (!widget) return "Unknown Widget" 33 | return widget.name 34 | } 35 | } 36 | 37 | ``` 38 | 39 | **widgets.test.ts** 40 | ```javascript 41 | import { widgets } from './widgets' 42 | 43 | describe("getWidgetName", () => { 44 | test("unknown widget returns 'Unknown Widget'", () => { 45 | const fakeApi = { 46 | getWidget: async () => undefined 47 | } 48 | 49 | // pass in the test implementation 50 | expect(getWidgetName(42, fakeApi)).toEqual('Unknown Widget') 51 | }) 52 | }) 53 | ``` 54 | 55 | ## A common usage: getting the current time 56 | 57 | Testing time can be annoying. For example, we might have a function that returns a natural language description of the age of some entity in our system. Let's say, for example a comment on a blog post: 58 | 59 | ```javascript 60 | const describeAge = (comment) => { 61 | 62 | const ageInMilliseconds = Date.now() - comment.createdAt 63 | if (ageInMilliseconds < 60 * 1000) { 64 | return "Just now" 65 | } 66 | 67 | ... a bunch more logic here to diplay minutes, hours, days, weeks, etc. .... 68 | 69 | } 70 | ``` 71 | 72 | Testing this requires generating different comment objects based on the current computer date. Since the computer date is changing as the tests run, it also makes it difficult to test boundary conditions. A simple solution is to add an additional argument with a default value: 73 | 74 | ```javascript 75 | const describeAge = (comment, now = Date.now()) => { 76 | 77 | const ageInMilliseconds = now - comment.createdAt 78 | if (ageInMilliseconds < 60 * 1000) { 79 | return "Just now" 80 | } 81 | 82 | ... a bunch more logic here to diplay minutes, hours, days, weeks, etc. .... 83 | 84 | } 85 | ``` 86 | 87 | Now it is simple to pass in a date for testing, and at runtime the current date will use used at all times. 88 | 89 | 90 | ## Notes 91 | 92 | This is a general technique that can be used anywhere, not only in react-aware code. 93 | 94 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/05-class-constructors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Class constructors 3 | --- 4 | 5 | ## Description 6 | 7 | A class takes the required services in its constructor, and stores them in instance variables. 8 | 9 | In production, we can either default the values of these variables or expose an instance of the class, constructed with the production services. 10 | 11 | In tests, pass test implementations into the constructor. 12 | 13 | 14 | ## Example 15 | 16 | **api.ts** 17 | 18 | ```js 19 | // production api implementation 20 | export const api = { 21 | getWidget: (id) => { ... } 22 | } 23 | ``` 24 | 25 | **widgets.ts** 26 | ```js 27 | import { api } from './api' 28 | 29 | export class Widgets { 30 | constructor(api) { 31 | this.api = api 32 | } 33 | 34 | async getWidgetName(widgetId) { 35 | const widget = await this.api.getWidget(widgetId); 36 | if (!widget) return "Unknown Widget" 37 | return widget.name 38 | } 39 | } 40 | 41 | // optional -- export a production version for import elsewhere 42 | export const widgets = new Widgets(api) 43 | 44 | ``` 45 | 46 | **widgets.test.ts** 47 | ```js 48 | import { widgets } from './widgets' 49 | 50 | describe("getWidgetName", () => { 51 | test("unknown widget returns 'Unknown Widget'", async () => { 52 | const fakeApi = { 53 | getWidget: async () => undefined 54 | } 55 | 56 | // construct the class with the test implementation 57 | const widgets = new Widgets(fakeApi) 58 | 59 | const name = await widgets.getWidgetName(42) 60 | 61 | expect(name).toEqual('Unknown Widget') 62 | }) 63 | }) 64 | ``` 65 | 66 | 67 | ## Notes 68 | 69 | This requires the use of classes. 70 | 71 | The mechanics are very similar to [closures](./closures) 72 | 73 | On many projects, classes are not commonly used. If that is the case, consider using [closures](./closures). 74 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/06-setters.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Setter function 3 | --- 4 | 5 | ## Description 6 | 7 | Store a reference to a dependency in a private variable and provide a setter function that sets the value of the variable. 8 | 9 | In production, there are a few options: 10 | 11 | 1. Default the value of the variable to the production implementation 12 | 1. Default the value of the variable to a test or no-op implementation 13 | 1. Upon startup, construct the production implementations and invoke the setters on each module that needs them. 14 | 15 | In tests, invoke the setter with a test implementation, often a fake or a stub. 16 | 17 | ## Example 18 | 19 | **api.js** 20 | 21 | ```js 22 | // production api implementation 23 | export const api = { 24 | getWidget: (id) => { ... } 25 | } 26 | ``` 27 | 28 | **widgets.js** 29 | ```js 30 | let widgetApi; 31 | 32 | export const setApi = (implementation) => { 33 | widgetApi = implementation 34 | } 35 | 36 | export const widgets = { 37 | getWidgetName: async (widgetId) => { 38 | const widget = await widgetApi.getWidget(widgetId); 39 | if (!widget) return "Unknown Widget" 40 | return widget.name 41 | } 42 | } 43 | 44 | ``` 45 | 46 | **widgets.test.js** 47 | ```js 48 | import { widgets, setApi } from './widgets' 49 | 50 | describe("getWidgetName", () => { 51 | test("unknown widget returns 'Unknown Widget'", () => { 52 | const fakeApi = { 53 | getWidget: async () => undefined 54 | } 55 | 56 | // use the test implementation 57 | setApi(fakeApi) 58 | 59 | expect(getWidgetName(42, fakeApi)).toEqual('Unknown Widget') 60 | }) 61 | }) 62 | ``` 63 | 64 | ## Notes 65 | 66 | This is a general technique that can be used anywhere, not only in react-aware code. 67 | 68 | ## Pros 69 | 70 | - Explicit and simple to implement 71 | - Works well for optional or occasionally overriden dependencies 72 | 73 | ## Cons 74 | 75 | - If there is no default value, objects can be in invalid state until their setters have been invoked 76 | - Can lead to tricky/verbose wiring code to set initial dependencies upon app startup 77 | 78 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/07-react-props.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: React props 3 | --- 4 | 5 | ## Description 6 | 7 | Add services to each function that uses them as arguments. 8 | 9 | In production, use default prop value to bring in the production implementation. 10 | 11 | In tests, pass in a test implementation as a prop. 12 | 13 | 14 | ## Example 15 | 16 | **api.js** 17 | 18 | ```javascript 19 | // production api implementation 20 | export const api = { 21 | getWidget: (id) => { ... } 22 | } 23 | ``` 24 | 25 | **WidgetName.jsx** 26 | ```javascript 27 | import { api } from './api' 28 | import React, { useEffect, useState } from 'react' 29 | 30 | const WidgetName = ({ 31 | widgetApi=api, // default value is the "real" api 32 | id 33 | }) => { 34 | const [name, setName] = useState('') 35 | 36 | useEffect(() => { 37 | widgetApi.getWidget(id).then(widget => setName(widget ? widget.name: 'Unknown Widget')) 38 | }, [id]) 39 | 40 | return {name} 41 | } 42 | 43 | ``` 44 | 45 | **WidgetName.test.jsx** 46 | 47 | ```javascript 48 | import { isolateComponent } from 'isolate-react' 49 | import { widgets } from './widgets' 50 | 51 | describe("WidgetName", () => { 52 | test("unknown widget returns 'Unknown Widget'", async () => { 53 | const fakeApi = { 54 | getWidget: async () => undefined 55 | } 56 | 57 | const widgetComponent = isolateComponent() 58 | 59 | // Wait for the api promise to resolve and update the component 60 | await widgetComponent.waitForRender() 61 | 62 | expect(widgetComponent.findOne('span').content()).toEqual('Unknown Widget') 63 | }) 64 | }) 65 | ``` 66 | 67 | ## Notes 68 | 69 | This is a react-specific technique. 70 | 71 | This is very similar to [default arguments](./default-arguments) 72 | -------------------------------------------------------------------------------- /docusaurus/docs/More/Dependencies/08-react-context.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: React context 3 | --- 4 | 5 | ## Description 6 | 7 | Create a react context that holds the dependency. 8 | 9 | The context can have a default value, or it can be set via a provider in the production application. 10 | 11 | Access the dependency via `useContext`. 12 | 13 | The context can live with either the dependent code, or the dependency. 14 | 15 | If you choose to locate the context with the dependency, you may wish to export a named wrapper that invokes useContext. 16 | 17 | In tests, use `setContext` or `withContext` to set the value of the context to the test implementation. 18 | 19 | ## Notes 20 | 21 | This is a react-specific technique. 22 | 23 | ## Example 24 | 25 | **api.js** 26 | ```javascript 27 | import { createContext, useContext } from 'react' 28 | 29 | // production api implementation 30 | export const api = { 31 | getWidget: (id) => { ... } 32 | } 33 | 34 | export const ApiContext = createContext(api) 35 | 36 | // This hook is used anywhere api is needed 37 | export const useApi = () => useContext(ApiContext) 38 | ``` 39 | 40 | **WidgetName.jsx** 41 | ```javascript 42 | import { useApi } from './api' 43 | import React, { useEffect, useState } from 'react' 44 | 45 | const WidgetName = ({ 46 | id 47 | }) => { 48 | const api = useApi() 49 | 50 | const [name, setName] = useState('') 51 | 52 | useEffect(() => { 53 | api.getWidget(id).then(widget => setName(widget ? widget.name: 'Unkonwn Widget')) 54 | }, [id]) 55 | 56 | return {name} 57 | } 58 | 59 | ``` 60 | 61 | **WidgetName.test.jsx** 62 | 63 | ```javascript 64 | import { isolateComponent } from 'isolate-react' 65 | import { ApiContext } from './api' 66 | import { widgets } from './widgets' 67 | 68 | describe("WidgetName", () => { 69 | test("unknown widget returns 'Unknown Widget'", async () => { 70 | const fakeApi = { 71 | getWidget: async () => undefined 72 | } 73 | 74 | const isolateWithFakeApi = isolateComponent.withContext(ApiContext, fakeApi) 75 | 76 | const widgetComponent = isolateWithFakeApi() 77 | 78 | // Wait for the api promise and update the component 79 | await widgetComponent.waitForRender() 80 | 81 | expect(widgetComponent.findOne('span').content()).toEqual('Unknown Widget') 82 | }) 83 | }) 84 | ``` 85 | 86 | -------------------------------------------------------------------------------- /docusaurus/docs/More/You Do Not Need/00-overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: A compendium of tools that you do not need 3 | --- 4 | 5 | As developers, we love dev tools. 6 | 7 | They are helpful (until we try to use them for our specific problem that is *almost* the same as the intended usage). 8 | 9 | They save us time (until a transitive dependency update breaks our application). 10 | 11 | Every library we install has a cost and (hopefully) also provides a benefit. This is a collection of tools that have a greater cost than benefit, for most teams. 12 | 13 | 14 | -------------------------------------------------------------------------------- /docusaurus/docs/More/You Do Not Need/01-replace-method-on-existing-object.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Replace existing method (jest.spyOn) 3 | sidebar_label: Code Smell - Replace existing method (jest.spyOn) 4 | --- 5 | 6 | ## Don't use jest.spyOn 7 | -------------------------------------------------------------------------------- /docusaurus/docs/More/You Do Not Need/02-file-mocks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mocking imports (jest.mock) 3 | sidebar_label: Code smell - Mocking imports (jest.mock) 4 | --- 5 | 6 | ## Don't use file mocks 7 | -------------------------------------------------------------------------------- /docusaurus/docs/More/You Do Not Need/03-mocking-tools.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: You don't need a fancy mocking tool 3 | --- 4 | 5 | ### This is a work in progress! 6 | 7 | ## What we talk about when we talk about mocks 8 | 9 | In automated tests, sometimes we use helper objects or functions that help us test *other* objects or functions. These helpers are often (semi-accurately) called "mocks". 10 | 11 | The more general term for this type of object is "test double". Technically, a "mock" is a specific type of test double. However usage of mocks (in the technical sense) is rare in practice, in javascript. 12 | Usage of the name "mock", as a synonym for "test double", probably comes from the names of many of the early tools, such as "JMock" for Java and "Rhino Mocks". 13 | 14 | Anyway, human language is even more malleable than Javascript, so just be warned that, in these writings, I will usually use a term like "fake" or "test double" in the general case, rather than the (imprecise) use of "mock" as a general term for all fake objects used in tests. 15 | 16 | ## Why do we even have mocking tools? 17 | 18 | At the time that TDD was gaining popularity, Java and C# did not have many built-in dynamic features, so tools that provide the developer the ability to easily create an object for testing allowed for exercising modules in tests in ways that would otherwise be difficult. 19 | 20 | However, this power is not without cost. Using a tool that makes it easy to test a module that has many dependencies can make it harder to see the cost of those dependencies. 21 | Usually, in my experience, code that is hard to test is also hard to understand and ultimately, hard to make correct. 22 | Additionally, usually when we say that code is "hard to test" what we mean is that it is difficult to set up the *dependencies* required to exercise that code. 23 | When testing a complex module, each test that does complex setup of dependencies makes it more difficult to refactor the dependencies of the code being tested, since that would now require changing the tests as well. It also makes the tests more difficult to understand. 24 | 25 | ## Javascript is malleable 26 | 27 | The dynamic nature of Javascript (and typescript) gives us many more options for 28 | 29 | ## What 30 | 31 | ### Stub 32 | #### Return known values from a function to facilitate testing 33 | 34 | ### Spy 35 | #### Spy on calls to a function and assert that it was called correctly 36 | 37 | 38 | -------------------------------------------------------------------------------- /docusaurus/docs/More/You Do Not Need/04-fancy-assertions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: You don't need an assertion library 3 | --- 4 | 5 | We've grown accustomed to fancy assertions. But do they provide value? 6 | 7 | These are the assertions that I regularly use in my day-to-day coding life: 8 | 9 | - assert that an object has a specific value 10 | - assert that an object has a specific shape 11 | - assert that a function throws a specific error 12 | - assert that a function rejects with a specific error 13 | - occasionally: assert about a boolean test 14 | 15 | 16 | 17 | `object a` is/is not the same instance as `object b` 18 | -------------------------------------------------------------------------------- /docusaurus/docs/More/You Do Not Need/05-fancy-test-runner.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/docs/More/You Do Not Need/05-fancy-test-runner.md -------------------------------------------------------------------------------- /docusaurus/docs/More/why.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: why 3 | title: Why? 4 | --- 5 | 6 | ## Why use isolate-react? 7 | 8 | Testing components and hooks in isolation should be fast and simple. This library makes it so. 9 | 10 | Approaches that require a DOM emulator to test react components will always be slow and indirect. This library enables direct testing of components. 11 | 12 | Other options that are fast, such as enzyme's `shallow`, rely on a poorly-maintained shallow renderer that is part of react. This renderer doesn't fully support hooks, so they don't support testing any functional component that uses useEffect or useContext. 13 | 14 | Fast, isolated automated testing tools are useful for test-driven development practices. 15 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/00-overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | --- 4 | 5 | `isolate-react` provides two functions for testing react components: 6 | 7 | - `isolateComponent` renders a react component *but does not render its children*. 8 | - `isolateComponentTree` renders a react component *and also renders any child components*. 9 | 10 | Both `isolateComponent` and `isolateComponentTree` return the same thing: an [IsolatedComponent](./api#IsolatedComponent) that provides methods for inspecting and interacting with the elements rendered by the component. 11 | 12 | The next couple of pages show examples of each. 13 | 14 | 15 | ### [API Documentation](./api.md) 16 | 17 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/01-anatomy-of-a-test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Anatomy of a component test 3 | --- 4 | 5 | Whether using `isolateComponent`, or `isolateComponentTree`, most component tests follow a similar pattern: 6 | 7 | 1. Isolate the component using `isolateComponent` or `isolateComponentTree` 8 | 2. Find children (components or dom elements) rendered by the isolated component. 9 | 3. Simulate some activity using the props of the children, or by updating the props of the isolated component. 10 | 4. Verify behavior by checking the content, using the same methods as in step 2. 11 | 12 | Let's say we want to create a button that counts how many times it's been clicked and displays that count: 13 | 14 | ```javascript 15 | import React, { useState } from 'react' 16 | 17 | // this is the component we want to test 18 | export const CounterButton = () => { 19 | const [count, setCount] = useState(0) 20 | return ( 21 |
22 | {count} 23 | 24 |
25 | ) 26 | } 27 | ``` 28 | 29 | Our plan is to: 30 | 1. Isolate the component using `isolateComponent` 31 | 2. Look at the content rendered by the isolated component to see that it is 0. 32 | 3. Simulate clicks on the button 33 | 4. Check the content to see that it is updated. 34 | 35 | Here's how that looks, using jest as the test runner: 36 | 37 | ```javascript 38 | import { isolateComponent } from 'isolate-react' 39 | import { CounterButton } from './CounterButton' 40 | 41 | test('starts at zero, then increments when clicked', () => { 42 | // 1. Isolate 43 | const button = isolateComponent() 44 | 45 | // 2. Verify it starts with zero. 46 | expect(button.content()).toContain('0') 47 | 48 | // 3. Simulate three clicks 49 | button.findOne('button').props.onClick() 50 | button.findOne('button').props.onClick() 51 | button.findOne('button').props.onClick() 52 | 53 | // 4. Verify it is now 3 54 | expect(button.content()).toContain('3') 55 | }) 56 | ``` 57 | 58 | ## Step by step: 59 | 60 | ### Step 1: isolate with `isolateComponent`: 61 | ```javascript 62 | const button = isolateComponent() 63 | ``` 64 | 65 | `isolateComponent` returns an IsolatedComponent. Check out [the api documentation](./api.md) for more information. 66 | 67 | ### Step 2: Verify it starts out with a zero value 68 | 69 | There are a few different ways to explore the isolated component's contents. 70 | 71 | `content()` returns all of the inner content of the component: 72 | 73 | ```javascript 74 | expect(button.content()).toContain('0') 75 | ``` 76 | 77 | We can also find the rendered `span` and check its content: 78 | 79 | ```javascript 80 | // find by element type 81 | expect(button.findOne('span').content()).toEqual('0') 82 | // find by className 83 | expect(button.findOne('.count').content()).toEqual('0') 84 | // find by element type and class name 85 | expect(button.findOne('span.count').content()).toEqual('0') 86 | ``` 87 | 88 | There are a few other ways to explore the contents of an isolated component. Two of the most useful are `findAll()` and `exists()` 89 | 90 | `findOne`, `findAll`, and `exists` each take a [Selector](./api.md). A Selector can be a string that supports a subset of CSS-style matchers. It can also be a component reference, which will be discussed later in this guide. 91 | 92 | ### Step 3: Simulate interactions 93 | 94 | Simulate interactions by using `findOne` or `findAll` to find rendered components or tags (called "nodes") and interacting with their props: 95 | 96 | ```javascript 97 | button.findOne('button').props.onClick() 98 | button.findOne('button').props.onClick() 99 | button.findOne('button').props.onClick() 100 | ``` 101 | 102 | ### Step 4: Verify the updated content 103 | 104 | Again, there are a few different ways to do this: 105 | 106 | ```javascript 107 | expect(button.content()).toContain('3') 108 | 109 | expect(button.findOne('span.count').content()).toEqual('3') 110 | ``` 111 | 112 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/02-isolateComponent.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: isolateComponent 3 | sidebar_label: isolateComponent - Test a single component 4 | --- 5 | 6 | ## Test a single component 7 | 8 | `isolateComponent` allows you to test a single component in isolation. 9 | 10 | The JSX elements returned by the component will not be rendered, similar to enzyme's `shallow()`. 11 | 12 | In this style of testing we render only the component we are testing, and test its logic. 13 | 14 | Let's say we want to create a button that counts how many times it's been clicked and displays that count: 15 | 16 | ```javascript 17 | import React, { useState } from 'react' 18 | 19 | // this is the component we want to test 20 | export const CounterButton = () => { 21 | const [count, setCount] = useState(0) 22 | return ( 23 |
24 | {count} 25 | 26 |
27 | ) 28 | } 29 | ``` 30 | 31 | We can test this by: 32 | 1. Isolating the component using `isolateComponent` 33 | 2. Looking at the content rendered by the isolated component to see that it is 0. 34 | 3. Simulating clicks on the button 35 | 4. Checking the content again, as in step 2. 36 | 37 | Here's how that would look, using jest as the test runner: 38 | 39 | ```javascript 40 | import { isolateComponent } from 'isolate-react' 41 | import { CounterButton } from './CounterButton' 42 | 43 | test('starts at zero, then increments when clicked', () => { 44 | // 1. Isolate 45 | const button = isolateComponent() 46 | 47 | // 2. Verify it starts with zero. 48 | expect(button.content()).toContain('0') 49 | 50 | // 3. Simulate three clicks 51 | button.findOne('button').props.onClick() 52 | button.findOne('button').props.onClick() 53 | button.findOne('button').props.onClick() 54 | 55 | // 4. Verify it is now 3 56 | expect(button.content()).toContain('3') 57 | }) 58 | ``` 59 | 60 | 61 | ## Step by step: 62 | 63 | ### Step 1: isolate with `isolateComponent`: 64 | ```javascript 65 | const button = isolateComponent() 66 | ``` 67 | 68 | `isolateComponent` returns an IsolatedComponent. Check out [the api documentation](./api.md) for more information. 69 | 70 | ### Step 2: Verify it starts out with a zero value 71 | 72 | There are a few different ways to explore the isolated component's contents. 73 | 74 | `content()` returns all of the inner content of the component: 75 | 76 | ```javascript 77 | expect(button.content()).toContain('0') 78 | ``` 79 | 80 | We can also find the rendered `span` and check its content: 81 | 82 | ```javascript 83 | // find by element type 84 | expect(button.findOne('span').content()).toEqual('0') 85 | // find by className 86 | expect(button.findOne('.count').content()).toEqual('0') 87 | // find by element type and class name 88 | expect(button.findOne('span.count').content()).toEqual('0') 89 | ``` 90 | 91 | There are a few other ways to explore the contents of an isolated component. Two of the most useful are `findAll()` and `exists()` 92 | 93 | `findOne`, `findAll`, and `exists` each take a [Selector](./api.md). A Selector can be a string that supports a subset of CSS-style matchers. It can also be a component reference, which will be discussed later in this guide. 94 | 95 | ### Step 3: Simulate interactions 96 | 97 | Simulate interactions by using `findOne` or `findAll` to find rendered components or tags (called "nodes") and interacting with their props: 98 | 99 | ```javascript 100 | button.findOne('button').props.onClick() 101 | button.findOne('button').props.onClick() 102 | button.findOne('button').props.onClick() 103 | ``` 104 | 105 | ### Step 4: Verify the updated content 106 | 107 | Again, there are a few different ways to do this: 108 | 109 | ```javascript 110 | expect(button.content()).toContain('3') 111 | 112 | expect(button.findOne('span.count').content()).toEqual('3') 113 | ``` 114 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/03-isolateComponentTree.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: isolateComponentTree 3 | sidebar_label: isolateComponentTree - Test multiple components 4 | --- 5 | 6 | ## Test a component with all of its children 7 | 8 | Sometimes we want to test a component by rendering its entire component tree. You may be familiar with this technique from using enzyme's `mount` functionality or `react-testing-library`. 9 | 10 | We can use `isolateComponentTree` for this. 11 | 12 | 13 | Let's take an example of a shopping list component that allows adding and removing items from a list: 14 | 15 | ```typescript 16 | export const ShoppingList = () => { 17 | // items in the shopping list 18 | const [items, setItems] = useState([]) 19 | 20 | // the id of the next item, used when adding an item to the list 21 | const [nextId, setNextId] = useState(1) 22 | 23 | // Render a for each item in the list, 24 | // And at the end to allow adding an item 25 | return ( 26 |
    27 | {items.map((item) => ( 28 | { 32 | setItems(items.filter((i) => i.id !== item.id)) 33 | }} 34 | /> 35 | ))} 36 | { 38 | const id = nextId 39 | setNextId((nextId) => nextId + 1) 40 | setItems([...items, { description, id }]) 41 | }} 42 | /> 43 |
44 | ) 45 | } 46 | 47 | export const ShoppingListItem = (props: { 48 | item: Item 49 | onDeleteItem: () => void 50 | }) => ( 51 |
  • 52 | {props.item.description} 53 | 56 |
  • 57 | ) 58 | 59 | export const AddItem = (props: { 60 | onAddItem: (description: string) => void 61 | }) => { 62 | const [description, setDescription] = useState('') 63 | return ( 64 |
  • 65 | 74 | 84 |
  • 85 | ) 86 | } 87 | ``` 88 | 89 | We can use `isolateComponentTree` to test all of these components together: 90 | 91 | 92 | ```javascript 93 | test('add a shopping list item', () => { 94 | const isolated = isolateComponentTree() 95 | 96 | isolated 97 | // find the input element by name 98 | .findOne('input[name=description]') 99 | // simulate a change event 100 | .props.onChange({ target: { value: 'Avocado' } }) 101 | 102 | // find the Add button by class name 103 | isolated.findOne('button.add-item').props.onClick() 104 | 105 | // We should have two lis: shopping list item and "add item" 106 | expect(isolated.findAll('li').length).toEqual(2) 107 | 108 | // assert that the description matches the input 109 | expect(isolated.findOne('span.item-description').content()).toEqual( 110 | 'Avocado' 111 | ) 112 | }) 113 | ``` 114 | 115 | The choice between testing components individually or together has different tradeoffs depending on the components being tested. 116 | 117 | In general, testing components together gives confidence in the way the components integrate with each other, at the cost of increased [coupling](https://en.wikipedia.org/wiki/Coupling_(computer_programming)) between tests and implementations. 118 | 119 | 120 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/04-testing-with-react-context.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing useContext 3 | --- 4 | 5 | Two methods of setting context are provided, and they work the same for `isolateComponent` and `isolateComponentTree`: 6 | 7 | ### setContext 8 | 9 | The `setContext` method supports setting a context value for testing. 10 | 11 | Context is often used to store application level state that is accessed in various places throughout a react application. 12 | 13 | Here's an example where context is used to store the name of the current user: 14 | 15 | ```typescript 16 | 17 | const userContext = React.createContext({firstName: '', lastName: ''}) 18 | 19 | const CurrentUserName = () => { 20 | const user = useContext(userContext) 21 | 22 | return {user.firstName} {user.lastName} 23 | } 24 | 25 | ``` 26 | 27 | We can use `setContext` to test the `CurrentUserName` component. For example, with jest our test might look like this: 28 | 29 | ```typescript 30 | test('renders the name', () => { 31 | const nameComponent = isolateComponent() 32 | nameComponent.setContext(userContext, {firstName: 'Arthur', lastName: 'Dent'}) 33 | expect(nameComponent.findOne('span').text()).toEqual('Arthur Dent') 34 | }) 35 | ``` 36 | 37 | ### withContext 38 | 39 | You can also set the context used for the *initial* render using the `isolateComponent.withContext` method. That works a little bit differently, and looks like this: 40 | 41 | ```typescript 42 | test('renders the name, init', () => { 43 | const nameComponent = isolateComponent.withContext({firstName: 'Arthur', lastName: 'Dent'})( ) 44 | expect(nameComponent.findOne('span').text()).toEqual('Arthur Dent') 45 | }) 46 | ``` 47 | 48 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/05-testing-effects.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing effects 3 | --- 4 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/06-testing-component-unmount.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing component unmount 3 | --- 4 | 5 | The `cleanup()` method unmounts your component and any inlined components. 6 | 7 | (More docs to come) 8 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/07-refs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Working with refs 3 | --- 4 | 5 | Use `setRef` to set the value of a ref for testing. 6 | 7 | (More docs to come) 8 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Components/08-portals.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/docs/Testing Components/08-portals.md -------------------------------------------------------------------------------- /docusaurus/docs/Testing Hooks/01-overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | --- 4 | 5 | `isolateHook` lets you test your custom react hooks quickly and simply. 6 | 7 | Here's a simple example: 8 | 9 | ```javascript 10 | import { useState } from 'react' 11 | import { isolateHook } from 'isolate-react' 12 | 13 | const useCounter = () => { 14 | const [count, setCount] = useState(0) 15 | 16 | return { 17 | count, 18 | increment: () => setCount(x => x + 1) 19 | } 20 | } 21 | 22 | // isolateHook returns a function with the same arguments 23 | // and return type as the passed hook 24 | const isolated = isolateHook(useCounter) 25 | 26 | console.log(isolated().count) // => 0 27 | 28 | isolated().increment() 29 | 30 | console.log(isolated().count) // => 1 31 | 32 | // isolated hooks have some other helper methods: 33 | console.log(isolated.currentValue().count) // => 1 34 | ``` 35 | 36 | For more details see the [isolateHook API Documentation](./api.md) 37 | 38 | 39 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Hooks/02-testing-effects.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hooks with effects 3 | --- 4 | 5 | `isolateHook` supports testing customer hooks that use any of various react effects: 6 | 7 | `useEffect` 8 | `useLayoutEffect` 9 | `useInsertionEffect` 10 | 11 | Here's a hook that logs when the value of `name` changes: 12 | 13 | ```javascript 14 | const useHelloGoodbye = (name) => { 15 | useEffect(() => { 16 | console.log(`Hello, ${name}`) 17 | return () => {console.log(`Goodbye, ${name}`)} 18 | }, [name]) 19 | }t 20 | ``` 21 | 22 | ```javascript 23 | const useTestHelloGoobye = isolateHook(useHelloGoodbye) 24 | 25 | useTestHelloGoobye('Arthur') // => logs 'Hello Arthur' 26 | useTestHelloGoobye('Arthur') // => does nothing (no change to effect dependency) 27 | useTestHelloGoobye('Trillian') // => logs 'Goodbye Arthur' and 'Hello Trillian' 28 | ``` 29 | 30 | ## Simulating hook cleanup 31 | 32 | You can use the `cleanup` function to simulate the cleanup that happens when a hook is torn down; 33 | 34 | ```javascript 35 | const useTestHelloGoobye = isolateHook(useHelloGoodbye) 36 | 37 | useTestHelloGoobye('Arthur') // => logs 'Hello Arthur' 38 | useTestHelloGoobye('Trillian') // => logs 'Goodbye Arthur' and 'Hello Trillian' 39 | useTestHelloGoobye.cleanup() // => logs 'Goodbye Trillian' 40 | ``` 41 | 42 | Refer to the [cleanup API docs](./api.md#cleanup) for more information 43 | 44 | ## Notes 45 | 46 | ### Effects are executed synchronously 47 | 48 | When effect dependencies are changed, all affected effects are executed synchronously *before* the isolated hook returns. 49 | 50 | ### Testing effects with async functions or promises 51 | 52 | Sometimes an effect will have asynchronous behavior, such as fetching some data from a remote api. 53 | In this case you may want to wait for the asynchronous operation to complete before proceeding. 54 | 55 | In this case, use the [waitForUpdate](./api#waitForUpdate) method to wait fo the hook to be updated. 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Hooks/03-testing-context.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing context 3 | --- 4 | 5 | Testing hooks that use [useContext](https://reactjs.org/docs/hooks-reference.html#usecontext)is simple with isolateHook. 6 | 7 | Isolated hooks provide the `setContext` method to set a context value. 8 | 9 | Refer to the [setContext API docs](./api.md#setcontextcontexttype-value) for more information 10 | -------------------------------------------------------------------------------- /docusaurus/docs/Testing Hooks/04-testing-async-hooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing asynchronous hooks 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /docusaurus/docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | --- 4 | 5 | `isolate-react` exposes three functions: `isolateHook`, `isolateComponent`, and `isolateComponentTree`. 6 | 7 | Import them as follows: 8 | 9 | ```javascript 10 | import { isolateComponent, isolateComponentTree, isolateHook } from 'isolate-react' 11 | ``` 12 | 13 | Each function serves a different purpose: 14 | 15 | - `isolateHook` is used for testing custom hooks. 16 | - `isolateComponent` is used for testing a single compoment *without* rendering its child components, similar to "shallow" rendering with a tool like enzyme. 17 | - `isolateComponentTree` is used for testing a compoment *with* all of its children. 18 | 19 | Learn about testing components: 20 | 21 | * [Overview and Examples](./Testing Components/01-overview.md) 22 | * [API](./Testing Components/api.md) 23 | 24 | Learn about testing hooks: 25 | 26 | * [Overview and Examples](./Testing Hooks/01-overview.md) 27 | * [API](./Testing Hooks/api.md) 28 | -------------------------------------------------------------------------------- /docusaurus/docs/compared-to.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: compared-to 3 | title: Comparison to other tools 4 | --- 5 | 6 | ## How does this compare to (insert tool here)? 7 | 8 | ### enzyme shallow 9 | 10 | Enzyme shallow works great for react class components but doesn't support the full set of hooks necessary to build stateful functional components. 11 | 12 | ### enzyme mount and react-testing-library 13 | 14 | These tools allow testing components and hooks but they: 15 | 16 | 1. Require a dom emulator. This makes tests run _very_ slowly compared to tests that use isolate-react. 17 | 1. Require testing _all_ rendered components. This is _sometimes_ desirable but often is not. isolate-react allows you to test a single component in isolation, or to test multiple components together -- it's up to you. 18 | 19 | ### cypress, selenium, etc. 20 | 21 | Cypress and similar tools are used for _acceptance testing_. `isolate-react` facilitates isolated testing of a single component (_unit testing_) or a small set of components. Acceptance testing is orthogonal to unit testing -- you can do either or both. 22 | 23 | -------------------------------------------------------------------------------- /docusaurus/docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: installation 3 | title: Installation 4 | --- 5 | 6 | # Installation 7 | 8 | You probably want to install `isolate-react` as a dev dependency into your project that uses react: 9 | 10 | `yarn add --dev isolate-react` or `npm install -D isolate-react` 11 | 12 | `isolate-react` has no other dependencies and ships with included typescript types. 13 | 14 | -------------------------------------------------------------------------------- /docusaurus/docs/main.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: main 3 | title: isolate-react 4 | slug: / 5 | --- 6 | 7 | ## The missing tool for test-driving react hooks and components 8 | 9 | * No DOM emulator required 10 | * Supports any test runner 11 | * Zero dependencies 12 | * Simple-to-use 13 | * Guaranteed faster than other react testing tools, or your money back 14 | 15 | ## Examples/TLDR 16 | 17 | ### Test a component: 18 | 19 | ```typescript 20 | // the component we want to test 21 | import React, { useState } from 'react' 22 | import { isolateComponent } from 'isolate-react' 23 | 24 | const CounterButton = () => { 25 | const [count, setCount] = useState(0) 26 | return ( 27 |
    28 | {count} 29 | 32 |
    33 | ) 34 | } 35 | 36 | test('starts at zero', () => { 37 | const counterButton = isolateComponent() 38 | expect(counterButton.findOne('span.count').content()).toEqual('0') 39 | }) 40 | 41 | test('increments upon click', () => { 42 | const counterButton = isolateComponent() 43 | 44 | counterButton.findOne('button').props.onClick() 45 | expect(counterButton.findOne('span.count').content()).toEqual('1') 46 | }) 47 | ``` 48 | 49 | ### Test a hook 50 | 51 | ```typescript 52 | import { isolateHook } from 'isolate-react' 53 | 54 | import React, { useState, useEffect } from 'react' 55 | 56 | const useRememberNames = (name: string) => { 57 | const [names, setNames] = useState([name]) 58 | 59 | // when name changes, 60 | // add it to our list of names, 61 | // if we haven't seen it yet 62 | useEffect(() => { 63 | if (!names.includes(name)) { 64 | names.push(name) 65 | } 66 | }, [name]) 67 | 68 | return names 69 | } 70 | 71 | test('remembers the initial name', () => { 72 | const useTestRememberNames = isolateHook(useRememberNames) 73 | expect(useTestRememberNames('Arthur')).toEqual(['Arthur']) 74 | }) 75 | 76 | test('remembers two names', () => { 77 | const useTestRememberNames = isolateHook(useRememberNames) 78 | useTestRememberNames('Arthur') 79 | expect(useTestRememberNames('Trillian')).toEqual(['Arthur', 'Trillian']) 80 | }) 81 | 82 | test('does not remember duplicate names', () => { 83 | const useTestRememberNames = isolateHook(useRememberNames) 84 | useTestRememberNames('Ford') 85 | useTestRememberNames('Arthur') 86 | useTestRememberNames('Ford') 87 | 88 | expect(useTestRememberNames('Arthur')).toEqual(['Ford', 'Arthur']) 89 | }) 90 | ``` 91 | 92 | 93 | ## Why use isolate-react? 94 | 95 | ### Flexible support for whatever level of testing you prefer: 96 | - [x] Test custom hooks 97 | - [x] Render a single component at a time (isolated/unit testing) 98 | - [x] Render multiple components together (integrated testing) 99 | 100 | ### Low-friction: 101 | - [x] Works with any test runner that runs in node (jest, mocha, tape, tap, etc.) 102 | - [x] Full hook support 103 | - [x] Easy access to set context values needed for testing 104 | - [x] No virtual DOM or other tools to install 105 | - [x] Very fast 106 | 107 | 108 | See the [API documentation](./api.md) for usage, or jump right into the documentation: 109 | * [isolateComponent](./Testing Components/02-isolateComponent.md) 110 | * [isolateComponentTree](./Testing Components/03-isolateComponentTree.md) 111 | * [isolateHook](./Testing Hooks/01-overview.md) 112 | 113 | ### Issues & Progress 114 | 115 | See the [project tracker](https://github.com/davidmfoley/isolate-react/projects/1) for project progress. 116 | 117 | File an [issue](https://github.com/davidmfoley/isolate-react/issues) if you have a suggestion or request. 118 | -------------------------------------------------------------------------------- /docusaurus/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 5 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: 'isolate-react', 10 | tagline: 'The missing tool for testing react applications', 11 | url: 'https://davidmfoley.github.io', 12 | baseUrl: '/isolate-react/', 13 | onBrokenLinks: 'throw', 14 | onBrokenMarkdownLinks: 'warn', 15 | favicon: 'img/favicon.ico', 16 | organizationName: 'davidmfoley', // Usually your GitHub org/user name. 17 | projectName: 'isolate-react', // Usually your repo name. 18 | 19 | presets: [ 20 | [ 21 | '@docusaurus/preset-classic', 22 | /** @type {import('@docusaurus/preset-classic').Options} */ 23 | ({ 24 | docs: { 25 | routeBasePath: '/', 26 | sidebarPath: require.resolve('./sidebars.js'), 27 | // Please change this to your repo. 28 | editUrl: 'https://github.com/davidmfoley/isolate-react/docusaurus', 29 | }, 30 | theme: { 31 | customCss: require.resolve('./src/css/custom.css'), 32 | }, 33 | }), 34 | ], 35 | ], 36 | 37 | themeConfig: 38 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 39 | ({ 40 | navbar: { 41 | title: 'isolate-react', 42 | items: [ 43 | { 44 | href: 'https://github.com/davidmfoley/isolate-react', 45 | label: 'GitHub', 46 | position: 'right', 47 | }, 48 | { 49 | href: 'https://npmjs.com/package/isolate-react', 50 | label: 'NPM', 51 | position: 'right', 52 | }, 53 | ], 54 | }, 55 | footer: { 56 | style: 'dark', 57 | copyright: `Copyright © ${new Date().getFullYear()} Dave Foley`, 58 | }, 59 | prism: { 60 | theme: lightCodeTheme, 61 | darkTheme: darkCodeTheme, 62 | }, 63 | }), 64 | }; 65 | 66 | module.exports = config; 67 | -------------------------------------------------------------------------------- /docusaurus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isolate-react-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "TYPEDOC_WATCH=true docusaurus start --port ${PORT-3003}", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^2.0.0-beta.15", 19 | "@docusaurus/preset-classic": "^2.0.0-beta.15", 20 | "@mdx-js/react": "^1.6.21", 21 | "@svgr/webpack": "^5.5.0", 22 | "clsx": "^1.1.1", 23 | "file-loader": "^6.2.0", 24 | "prism-react-renderer": "^1.2.1", 25 | "react": "^17.0.1", 26 | "react-dom": "^17.0.1", 27 | "url-loader": "^4.1.1" 28 | }, 29 | "devDependencies": { 30 | "@docusaurus/module-type-aliases": "^2.0.0-beta.15", 31 | "@tsconfig/docusaurus": "^1.0.4", 32 | "typescript": "^4.3.5" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.5%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docusaurus/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | module.exports = { 13 | // By default, Docusaurus generates a sidebar from the docs folder structure 14 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 15 | 16 | // But you can create a sidebar manually 17 | /* 18 | tutorialSidebar: [ 19 | { 20 | type: 'category', 21 | label: 'Tutorial', 22 | items: ['hello'], 23 | }, 24 | ], 25 | */ 26 | }; 27 | -------------------------------------------------------------------------------- /docusaurus/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #400519; 10 | --ifm-color-primary-dark: #6b0829; 11 | --ifm-color-primary-darker: #960c39; 12 | --ifm-color-primary-darkest: #c10f4a; 13 | --ifm-color-primary-light: #ec135a; 14 | --ifm-color-primary-lighter: #f36996; 15 | --ifm-color-primary-lightest: #f794b4; 16 | --ifm-code-font-size: 95%; 17 | } 18 | 19 | .docusaurus-highlight-code-line { 20 | background-color: rgba(0, 0, 0, 0.1); 21 | display: block; 22 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 23 | padding: 0 var(--ifm-pre-padding); 24 | } 25 | 26 | html[data-theme='dark'] .docusaurus-highlight-code-line { 27 | background-color: rgba(0, 0, 0, 0.3); 28 | } 29 | 30 | a { 31 | color: var(--ifm-color-primary-light); 32 | } 33 | 34 | a.menu__link { 35 | color: var(--ifm-color-primary-darker) !important; 36 | } 37 | 38 | a.menu__link--sublist { 39 | color: var(--ifm-color-primary-darkest) !important; 40 | } 41 | 42 | .table-of-contents__link--active { 43 | color: var(--ifm-color-primary-light) !important; 44 | } 45 | 46 | -------------------------------------------------------------------------------- /docusaurus/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 966px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | -------------------------------------------------------------------------------- /docusaurus/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docusaurus/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/static/.nojekyll -------------------------------------------------------------------------------- /docusaurus/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docusaurus/wip/00-welcome.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: welcome 3 | title: Welcome 4 | --- 5 | 6 | -------------------------------------------------------------------------------- /docusaurus/wip/01-install.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: install-and-setup 3 | title: Installation and setup 4 | --- 5 | 6 | -------------------------------------------------------------------------------- /docusaurus/wip/02-quickstart.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/02-quickstart.md -------------------------------------------------------------------------------- /docusaurus/wip/compare-to.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: compare 3 | title: Comparison to other tools 4 | --- 5 | 6 | # How does this compare to (insert tool here)? 7 | 8 | ## enzyme shallow 9 | 10 | Enzyme shallow works great for react class components but doesn't support the full set of hooks necessary to build stateful functional components. 11 | 12 | ## enzyme mount and react-testing-library 13 | 14 | These tools allow testing components that use hooks but they: 15 | 16 | 1. Require a dom emulator. This makes tests run _very_ slow compared to isolate-components. 17 | 1. Require testing _all_ rendered components. This is _sometimes_ desirable but often is not. isolate-components allows you to test a single component in isolation, or to test multiple components together -- it's up to you. 18 | 19 | ## cypress, selenium, etc. 20 | 21 | Cypress and similar tools are used for _acceptance testing_. `isolate-components` facilitates isolated testing of a single component (_unit testing_) or a small set of components. Acceptance testing is orthogonal to unit testing -- you can do either or both. 22 | 23 | -------------------------------------------------------------------------------- /docusaurus/wip/context.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: context 3 | title: Context 4 | --- 5 | 6 | # Usage with useContext 7 | 8 | ## withContext() 9 | 10 | The `withContext` method supports setting context values before render for testing components that use `useContext`. 11 | 12 | ```js 13 | const QuestionContext = React.createContext('') 14 | const AnswerContext = React.createContext(0) 15 | 16 | const DisplayQuestionAndAnswer = () => ( 17 |
    18 | {React.useContext(QuestionContext)} {React.useContext(AnswerContext)} 19 |
    20 | ) 21 | 22 | const isolated = isolateComponent 23 | .withContext(QuestionContext, 'what is the answer?') 24 | .withContext( 25 | AnswerContext, 26 | 42 27 | )() 28 | 29 | console.log(isolated.toString()) // =>
    what is the answer? 42
    30 | ``` 31 | 32 | ## setContext() 33 | 34 | You can update context values of an isolated component with `.setContext()`: 35 | 36 | ```js 37 | const QuestionContext = React.createContext('') 38 | const AnswerContext = React.createContext(0) 39 | 40 | const DisplayQuestionAndAnswer = () => ( 41 |
    42 | {React.useContext(QuestionContext)} {React.useContext(AnswerContext)} 43 |
    44 | ) 45 | 46 | const isolated = isolateComponent() 47 | 48 | isolated.setContext(QuestionContext, 'what is the answer?') 49 | isolated.setContext(AnswerContext, 42) 50 | 51 | console.log(isolated.toString()) // =>
    what is the answer? 42
    52 | ``` 53 | 54 | -------------------------------------------------------------------------------- /docusaurus/wip/effects.md: -------------------------------------------------------------------------------- 1 | ### Effects 2 | 3 | Easily test components that use `useEffect`. 4 | Use `cleanup()` to test effect cleanup. 5 | 6 | ```js 7 | import { isolateComponent } from 'isolate-components' 8 | 9 | // Component with effect 10 | const EffectExample = (props) => { 11 | useEffect(() => { 12 | console.log(`Hello ${props.name}`) 13 | // cleanup function 14 | return () => { 15 | console.log(`Goodbye ${props.name}`) 16 | } 17 | }, [props.name]) 18 | return Hello {props.name} 19 | } 20 | 21 | // render the component, in isolation 22 | const component = isolateComponent() 23 | // logs: "Hello Trillian" 24 | 25 | component.setProps({ name: 'Zaphod' }) 26 | //logs: "Goodbye Trillian" (effect cleanup) 27 | //logs: "Hello Zaphod" (effect runs because name prop has changed) 28 | 29 | component.cleanup() 30 | //logs: "Goodbye Zaphod" 31 | ``` 32 | -------------------------------------------------------------------------------- /docusaurus/wip/inlining.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/inlining.md -------------------------------------------------------------------------------- /docusaurus/wip/limitations.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: limitations 3 | title: Limitations 4 | --- 5 | 6 | # Limitations 7 | 8 | ## Unsupported react features 9 | - [ ] Portals 10 | - [ ] Refs to DOM elements 11 | 12 | ## Testing without a DOM 13 | Certain things can be hard to test without a DOM: For example, 14 | 15 | -------------------------------------------------------------------------------- /docusaurus/wip/overview.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/overview.md -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/01-tdd.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: What is (and isn't) Test Driven Development? 3 | --- 4 | -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/02-not-about-testing-really.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/test-driven-react/02-not-about-testing-really.md -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/tactics/extract-hook-from-component.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Extract hook from component 3 | --- 4 | 5 | ## Extract the logic from a component into a hook. 6 | 7 | Reduce coupling between layout and component state by extracting the state, side effects, and related logic into a custom hook. 8 | 9 | Test the hook directly, and reduce the component's complexity as much as possible. 10 | 11 | -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/tactics/extract-hook-state-from-side-effects.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/test-driven-react/tactics/extract-hook-state-from-side-effects.md -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/tactics/extract-subcomponent.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Extract subsomponent from component 3 | --- 4 | 5 | ## Break up a complicated component by extracting subcomponents. 6 | 7 | ### Keep the props as simple as possible 8 | 9 | ### Separate state/side-effect logic from display logic 10 | 11 | ### Reference hooks in the top component, and pass simple values to subcomponents 12 | -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/tactics/inject-dependency.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/test-driven-react/tactics/inject-dependency.md -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/tactics/separate-side-effects-and-logic.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/test-driven-react/tactics/separate-side-effects-and-logic.md -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/tactics/use-context-for-composed-ui.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Use context to centralize state in complex user interface 3 | --- 4 | 5 | ## Use context to centralize state and side effects in a complex user interface 6 | 7 | Some interfaces feature layouts that are complex, where components that reference the shared state can be rendered inside of components that have no direct knowledge of that state. 8 | 9 | A common example is the "current user". Often there is a context provider near the very top level of the react application that holds the current user and provides methods to `login`, `logout`, and `register`. 10 | 11 | In the application, we may want to render a different experience depending on whether the current user is logged in or not. Rather than pass that information all the way down from the top level, we can access it via the context. 12 | 13 | ```typescript 14 | 15 | const currentUserContext = React.createContext({loggedIn: false, name: ""}) 16 | 17 | const HelloUser = () => { 18 | const currentUser = React.useContext(currentUserContext) 19 | if (currentUser.loggedIn) { 20 | return Hello, {currentUser.name} 21 | } 22 | 23 | return null 24 | } 25 | 26 | const App = () => { 27 | const [currentUser, setCurrentUser] = useState({loggedIn: false, name: ""}) 28 | 29 | return ( 30 | 31 | {/* ...more components */} 32 | 33 | 34 | ) 35 | } 36 | ``` 37 | 38 | 39 | ### Wrap the context in a hook 40 | 41 | When I take the time to move state to a context, I also like to wrap that context with a hook. This decouples consuming code from the "how" and also lets me give the hook a nice name. 42 | 43 | ```typescript 44 | const currentUserContext = React.createContext({loggedIn: false, name: ""}) 45 | 46 | ``` 47 | 48 | ### Beware of stability 49 | 50 | ## Usages 51 | 52 | There are a few main cases where this is useful: 53 | 54 | ### Global State 55 | 56 | Use this pattern for "global" state like current user (see above) 57 | 58 | ### Composed interfaces that share state 59 | 60 | Sometime interfaces are dynamically composed of components that reference the same current state that is managed "above them" in the tree. 61 | 62 | For example, in a widget-tracking application, the route `/widgets/42` may contain a complex interface with views of the widget inventory, sales data, and pricing. 63 | 64 | Moving the `currentWidget` lookup into a hook that is backed by context can reduce "prop-drilling" -- that is, passing prop values down to child components. 65 | -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/testing-promises.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/test-driven-react/testing-promises.md -------------------------------------------------------------------------------- /docusaurus/wip/test-driven-react/testing-time.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/test-driven-react/testing-time.md -------------------------------------------------------------------------------- /docusaurus/wip/two-ways-to-test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Two ways to test 3 | --- 4 | -------------------------------------------------------------------------------- /docusaurus/wip/typescript.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Typescript 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /docusaurus/wip/why.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmfoley/isolate-react/fa3c65f0b373baede4a22ffcbbf5ae7cae40806b/docusaurus/wip/why.md -------------------------------------------------------------------------------- /examples/CounterButton/CounterButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { isolateComponent } from 'isolate-react' 2 | import React from 'react' 3 | import { CounterButton } from './CounterButton' 4 | 5 | test('starts at zero', () => { 6 | const counterButton = isolateComponent() 7 | expect(counterButton.findOne('span.count').content()).toEqual('0') 8 | }) 9 | 10 | test('increments upon click', () => { 11 | const counterButton = isolateComponent() 12 | 13 | counterButton.findOne('button').props.onClick() 14 | expect(counterButton.findOne('span.count').content()).toEqual('1') 15 | }) 16 | -------------------------------------------------------------------------------- /examples/CounterButton/CounterButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | export const CounterButton = () => { 3 | const [count, setCount] = useState(0) 4 | return ( 5 |
    6 | {count} 7 | 10 |
    11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /examples/CounterButton/README.md: -------------------------------------------------------------------------------- 1 | A simple example showing the very basics of `isolateComponent` 2 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | A set of examples demonstrating the usage of `isolate-react`, using jest as the test runner. 2 | 3 | You can clone these examples and run them yourself: 4 | 5 | `git clone git@github.com:davidmfoley/isolate-react.git` 6 | 7 | `cd isolate-react/examples` 8 | 9 | `yarn` 10 | 11 | Run the examples once: 12 | 13 | `yarn examples` 14 | 15 | Watch the examples and run them on change: 16 | 17 | `yarn examples:watch` 18 | 19 | ## Examples 20 | 21 | ### [CounterButton]('./CounterButton') 22 | 23 | Demonstrates testing a simple component. 24 | 25 | ### [ShoppingList]('./ShoppingList') 26 | 27 | Demonstrates testing a react component with two approaches: 28 | 1. "Shallow" testing, using `isolateComponent` 29 | 2. "Deep" testing using `isolateComponentTree` 30 | 31 | ### [useRememberNames]('./useRememberNames') 32 | 33 | Demonstrates testing a hook that uses `useEffect` and `useState`. 34 | 35 | ### [useTodos]('./useTodos') 36 | 37 | Demonstrates testing a hook with slightly more complex state. 38 | -------------------------------------------------------------------------------- /examples/ShoppingList/README.md: -------------------------------------------------------------------------------- 1 | Example of a couple of different ways to test components that render other components: 2 | 3 | 1. In an isolated test 4 | 2. Using `inline()` to render child components and test them together 5 | -------------------------------------------------------------------------------- /examples/ShoppingList/ShoppingList.test.tsx: -------------------------------------------------------------------------------- 1 | import { isolateComponent, isolateComponentTree } from 'isolate-react' 2 | import React from 'react' 3 | import { AddItem, ShoppingList, ShoppingListItem } from './ShoppingList' 4 | 5 | describe('ShoppingList -- with isolateComponent', () => { 6 | test('starts empty', () => { 7 | const isolated = isolateComponent() 8 | expect(isolated.exists(ShoppingListItem)).toEqual(false) 9 | }) 10 | 11 | test('add a shopping list item', () => { 12 | const isolated = isolateComponent() 13 | isolated.findOne(AddItem).props.onAddItem('Avocado') 14 | expect(isolated.findAll(ShoppingListItem).length).toEqual(1) 15 | expect(isolated.findOne(ShoppingListItem).props.item.description).toEqual( 16 | 'Avocado' 17 | ) 18 | }) 19 | }) 20 | 21 | describe('ShoppingList -- with isolateComponentTree', () => { 22 | test('add a shopping list item', () => { 23 | const component = isolateComponentTree() 24 | 25 | component 26 | .findOne('input[name=description]') 27 | .props.onChange({ target: { value: 'Avocado' } }) 28 | 29 | component.findOne('button.add-item').props.onClick() 30 | 31 | expect(component.findAll('li').length).toEqual(2) 32 | expect(component.findOne('span.item-description').content()).toEqual( 33 | 'Avocado' 34 | ) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /examples/ShoppingList/ShoppingList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | interface Item { 4 | id: number 5 | description: string 6 | } 7 | 8 | export const ShoppingListItem = (props: { 9 | item: Item 10 | onDeleteItem: () => void 11 | }) => ( 12 |
  • 13 | {props.item.description} 14 | 17 |
  • 18 | ) 19 | 20 | export const AddItem = (props: { 21 | onAddItem: (description: string) => void 22 | }) => { 23 | const [description, setDescription] = useState('') 24 | return ( 25 |
  • 26 | 35 | 45 |
  • 46 | ) 47 | } 48 | 49 | export const ShoppingList = () => { 50 | const [items, setItems] = useState([]) 51 | const [nextId, setNextId] = useState(1) 52 | 53 | return ( 54 |
      55 | {items.map((item) => ( 56 | { 60 | setItems(items.filter((i) => i.id !== item.id)) 61 | }} 62 | /> 63 | ))} 64 | { 66 | const id = nextId 67 | setNextId((nextId) => nextId + 1) 68 | setItems([...items, { description, id }]) 69 | }} 70 | /> 71 |
    72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /examples/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ["**/**.test.{ts,tsx}"] 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isolate-react-examples", 3 | "version": "2.0.0", 4 | "repository": "https://github.com/davidmfoley/isolate-react", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "author": "davidmfoley@gmail.com", 8 | "license": "MIT", 9 | "private": true, 10 | "files": [ 11 | "lib/**/*" 12 | ], 13 | "scripts": { 14 | "examples": "jest", 15 | "examples:watch": "jest --watch", 16 | "prettier": "prettier -c './**/*.{ts,tsx}'", 17 | "fix:prettier": "prettier --write './**/*.{ts,tsx}'", 18 | "ci": "yarn examples && yarn prettier" 19 | }, 20 | "dependencies": {}, 21 | "devDependencies": { 22 | "@types/jest": "^27.4.0", 23 | "@types/react": "^17.0.39", 24 | "isolate-react": "^2.3.0", 25 | "jest": "^27.4.7", 26 | "prettier": "^2.5.1", 27 | "react": "^18.0.0", 28 | "ts-jest": "^27.1.2", 29 | "typescript": "^4.5.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "lib": [ "dom", "es2015" ], 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "sourceMap": true 12 | }, 13 | "include": ["."], 14 | "compileOnSave": false 15 | } 16 | -------------------------------------------------------------------------------- /examples/useRememberNames/README.md: -------------------------------------------------------------------------------- 1 | An example of testing a hook that uses useState and useEffect. 2 | 3 | -------------------------------------------------------------------------------- /examples/useRememberNames/useRememberNames.test.ts: -------------------------------------------------------------------------------- 1 | import { isolateHook } from 'isolate-react' 2 | import { useRememberNames } from './useRememberNames' 3 | 4 | test('remembers a single name', () => { 5 | const useTestRememberNames = isolateHook(useRememberNames) 6 | expect(useTestRememberNames('Arthur')).toEqual(['Arthur']) 7 | }) 8 | 9 | test('remembers two names', () => { 10 | const useTestRememberNames = isolateHook(useRememberNames) 11 | useTestRememberNames('Arthur') 12 | expect(useTestRememberNames('Trillian')).toEqual(['Arthur', 'Trillian']) 13 | }) 14 | 15 | test('does not remember duplicate names', () => { 16 | const useTestRememberNames = isolateHook(useRememberNames) 17 | useTestRememberNames('Ford') 18 | useTestRememberNames('Arthur') 19 | useTestRememberNames('Ford') 20 | 21 | expect(useTestRememberNames('Arthur')).toEqual(['Ford', 'Arthur']) 22 | }) 23 | -------------------------------------------------------------------------------- /examples/useRememberNames/useRememberNames.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useRememberNames = (name: string) => { 4 | const [names, setNames] = useState([name]) 5 | 6 | // when name changes, add it to our list of names 7 | useEffect(() => { 8 | if (!names.includes(name)) { 9 | setNames([...names, name]) 10 | } 11 | }, [name]) 12 | 13 | return names 14 | } 15 | -------------------------------------------------------------------------------- /examples/useTodos/README.md: -------------------------------------------------------------------------------- 1 | Example of using isolateHook to test a simple stateful hook. 2 | -------------------------------------------------------------------------------- /examples/useTodos/useTodos.test.ts: -------------------------------------------------------------------------------- 1 | import { useTodos } from './useTodos' 2 | import { isolateHook, IsolatedHook } from 'isolate-react' 3 | 4 | describe('useTodos', () => { 5 | let useTestTodos: IsolatedHook 6 | 7 | beforeEach(() => { 8 | useTestTodos = isolateHook(useTodos) 9 | }) 10 | 11 | it('initially has no todos', () => { 12 | expect(useTestTodos().todos).toEqual([]) 13 | }) 14 | 15 | it('can add a todo', () => { 16 | useTestTodos().addTodo('Escape planet earth') 17 | 18 | const { todos } = useTestTodos() 19 | 20 | expect(todos.length).toEqual(1) 21 | expect(todos[0].title).toEqual('Escape planet earth') 22 | }) 23 | 24 | describe('todo state', () => { 25 | beforeEach(() => { 26 | useTestTodos().addTodo('Escape planet earth') 27 | }) 28 | 29 | it('initially is todo', () => { 30 | expect(useTestTodos().todos[0].state).toEqual('todo') 31 | }) 32 | 33 | it('goes to doing when started', () => { 34 | useTestTodos().startTodo(useTestTodos().todos[0].id) 35 | expect(useTestTodos().todos[0].state).toEqual('doing') 36 | }) 37 | 38 | it('goes to done when started then finishedd', () => { 39 | useTestTodos().startTodo(useTestTodos().todos[0].id) 40 | useTestTodos().finishTodo(useTestTodos().todos[0].id) 41 | expect(useTestTodos().todos[0].state).toEqual('done') 42 | }) 43 | }) 44 | 45 | it('sorts todos in the order added', () => { 46 | useTestTodos().addTodo('Escape planet earth') 47 | useTestTodos().addTodo('Make tea') 48 | 49 | const { todos } = useTestTodos() 50 | 51 | expect(todos.length).toEqual(2) 52 | expect(todos[0].title).toEqual('Escape planet earth') 53 | expect(todos[1].title).toEqual('Make tea') 54 | }) 55 | 56 | it('can update an existing todo', () => { 57 | const isolated = isolateHook(useTodos) 58 | 59 | isolated().addTodo('Escape planet earth') 60 | isolated().addTodo('Make tea') 61 | 62 | isolated().updateTodoTitle(isolated().todos[1].id, 'Make proper tea') 63 | 64 | const { todos } = isolated() 65 | 66 | expect(todos.length).toEqual(2) 67 | expect(todos[0].title).toEqual('Escape planet earth') 68 | expect(todos[1].title).toEqual('Make proper tea') 69 | }) 70 | 71 | it('can delete a todo', () => { 72 | const isolated = isolateHook(useTodos) 73 | 74 | isolated().addTodo('Escape planet earth') 75 | isolated().addTodo('Make tea') 76 | 77 | isolated().deleteTodo(isolated().todos[0].id) 78 | 79 | const { todos } = isolated() 80 | 81 | expect(todos.length).toEqual(1) 82 | expect(todos[0].title).toEqual('Make tea') 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /examples/useTodos/useTodos.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | type TodoId = number 4 | 5 | interface Todo { 6 | id: TodoId 7 | title: string 8 | state: 'todo' | 'doing' | 'done' 9 | } 10 | 11 | export interface TodoState { 12 | todos: Todo[] 13 | addTodo: (title: string) => void 14 | deleteTodo: (id: TodoId) => void 15 | startTodo: (id: TodoId) => void 16 | finishTodo: (id: TodoId) => void 17 | updateTodoTitle: (id: TodoId, title: string) => void 18 | } 19 | 20 | let count = 0 21 | const nextId = () => count++ 22 | 23 | export const useTodos = (): TodoState => { 24 | const [todos, setTodos] = useState([]) 25 | 26 | const updateById = (id: TodoId, updater: (t: Todo) => Todo) => 27 | setTodos(todos.map((t) => (t.id === id ? updater(t) : t))) 28 | 29 | return { 30 | todos, 31 | 32 | addTodo: (title: string) => { 33 | const newTodo = { id: nextId(), title, state: 'todo' } 34 | setTodos([...todos, newTodo]) 35 | }, 36 | 37 | deleteTodo: (id: TodoId) => { 38 | setTodos(todos.filter((t) => t.id !== id)) 39 | }, 40 | 41 | updateTodoTitle: (id: TodoId, title: string) => 42 | updateById(id, (t) => ({ ...t, title })), 43 | 44 | startTodo: (id: TodoId) => 45 | updateById(id, (t) => ({ ...t, state: 'doing' })), 46 | 47 | finishTodo: (id: TodoId) => 48 | updateById(id, (t) => ({ ...t, state: 'done' })), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /isolate-react/README.md: -------------------------------------------------------------------------------- 1 | ## Test-drive react components and hooks 2 | 3 | `isolate-react` is the missing tool for test-driving your react components. 4 | 5 | It's focused on speed and simplicity, has zero dependencies, doesn't require a DOM emulator, and supports any test runner. 6 | 7 | ### Flexible support for whatever level of testing you prefer: 8 | - [x] Test react hooks 9 | - [x] Render a single component at a time (isolated/unit testing) 10 | - [x] Render multiple components toegether (integrated testing) 11 | 12 | ### Low -friction: 13 | - [x] No virtual DOM or other tools to install 14 | - [x] Works with any test runner that runs in node (jest, mocha, tape, tap, etc.) 15 | - [x] Full hook support 16 | - [x] Easy access to set context values for testing 17 | - [x] Very fast 18 | 19 | ### Render react components in isolation 20 | - [x] functional components that use hooks 21 | - [x] class components 22 | 23 | ## Links 24 | 25 | - [Documentation](https://davidmfoley.github.io/isolate-react/) 26 | - [GitHub](https://github.com/davidmfoley/isolate-react) 27 | - [npm](https://npmjs.com/package/isolate-react) 28 | 29 | -------------------------------------------------------------------------------- /isolate-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isolate-react", 3 | "version": "2.4.6", 4 | "repository": "https://github.com/davidmfoley/isolate-react", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "author": "davidmfoley@gmail.com", 8 | "license": "MIT", 9 | "private": false, 10 | "files": [ 11 | "lib/**/*" 12 | ], 13 | "scripts": { 14 | "build": "yarn clean && yarn build:tsc", 15 | "build:tsc": "tsc -p tsconfig.build.json", 16 | "clean": "rm -rf ./lib/", 17 | "test": "mocha", 18 | "test:watch": "nodemon -q -e ts,tsx --exec \"yarn test\" --watch src --watch test", 19 | "prettier": "prettier -c '{src,test}/**/*.{ts,tsx}'", 20 | "fix:prettier": "prettier --write '{src,test}/**/*.{ts,tsx}'", 21 | "cover": "COVERAGE=1 nyc mocha && open coverage/index.html", 22 | "ci": "yarn build:tsc && yarn test && yarn prettier" 23 | }, 24 | "devDependencies": { 25 | "@types/mocha": "^8.0.3", 26 | "@types/node": "^18.7.15", 27 | "@types/react": "^18.0.9", 28 | "@types/react-dom": "^18.0.6", 29 | "mocha": "^8.1.3", 30 | "nodemon": "^2.0.4", 31 | "nyc": "^15.1.0", 32 | "prettier": "^2.1.2", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "react-router-dom": "^5.3.0", 36 | "tsx": "^3.12.7", 37 | "typescript": "^4.0.3" 38 | }, 39 | "mocha": { 40 | "extension": [ 41 | "ts", 42 | "tsx" 43 | ], 44 | "spec": [ 45 | "./**/*.test.ts*" 46 | ], 47 | "loader": "tsx", 48 | "watch-files": [ 49 | "./src/**/*", 50 | "./test/**/*" 51 | ], 52 | "reporter": "dot", 53 | "recursive": true 54 | }, 55 | "nyc": { 56 | "extension": [ 57 | ".ts", 58 | ".tsx" 59 | ], 60 | "include": [ 61 | "src/**/*.ts" 62 | ], 63 | "reporter": [ 64 | "html" 65 | ], 66 | "all": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /isolate-react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './isolateHook' 2 | export * from './isolateComponent' 3 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/index.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './types/Selector' 2 | import { ComponentNode } from './types/ComponentNode' 3 | import { IsolatedComponent } from './types/IsolatedComponent' 4 | import { IsolateComponent } from './types/IsolateComponent' 5 | import { isolateComponent, isolateComponentTree } from './isolateComponent' 6 | 7 | export { Selector, ComponentNode } 8 | export { 9 | IsolatedComponent, 10 | IsolateComponent, 11 | isolateComponent, 12 | isolateComponentTree, 13 | } 14 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/applyProviderContext.ts: -------------------------------------------------------------------------------- 1 | import { componentIsContextProviderForType } from './componentIsContextProviderForType' 2 | import { RenderContext } from './renderContext' 3 | 4 | export const applyProviderContext = ( 5 | component: any, 6 | props: any, 7 | renderContext: RenderContext 8 | ) => { 9 | if ( 10 | component._context && 11 | componentIsContextProviderForType(component, component._context) 12 | ) { 13 | return renderContext.withContext(component._context, props.value) 14 | } 15 | return renderContext 16 | } 17 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/componentIsContextProviderForType.ts: -------------------------------------------------------------------------------- 1 | export const componentIsContextProviderForType = (component: any, t: any) => { 2 | return t === component?._context && component === t.Provider 3 | } 4 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/renderContext.ts: -------------------------------------------------------------------------------- 1 | import nodeMatcher, { NodeMatcher } from '../nodeMatcher' 2 | import { TreeNode } from '../types' 3 | import { Selector } from '../types/Selector' 4 | 5 | export type Contexts = { contextType: React.Context; contextValue: any }[] 6 | 7 | export interface RenderContext { 8 | contexts: Contexts 9 | addInlinedSelector: (selector: Selector) => void 10 | shouldInline: (node: TreeNode) => boolean 11 | withContext: (type: any, value: any) => RenderContext 12 | copy: () => RenderContext 13 | } 14 | 15 | export const makeRenderContext = ( 16 | contexts: Contexts, 17 | inlinedMatchers: NodeMatcher[] = [] 18 | ): RenderContext => { 19 | return { 20 | copy: () => makeRenderContext(contexts.slice(), inlinedMatchers), 21 | contexts, 22 | addInlinedSelector: (selector: Selector) => { 23 | inlinedMatchers.push(nodeMatcher(selector)) 24 | }, 25 | shouldInline: (node: TreeNode) => !!inlinedMatchers.find((m) => m(node)), 26 | withContext: (contextType: any, contextValue: any) => 27 | makeRenderContext( 28 | contexts 29 | .filter((c) => c.contextType !== contextType) 30 | .concat({ contextType, contextValue }), 31 | inlinedMatchers 32 | ), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/renderMethod.ts: -------------------------------------------------------------------------------- 1 | import { wrapClassComponent } from './wrapClassComponent' 2 | import { wrapContextConsumer } from './wrapContextConsumer' 3 | import { wrapContextProvider } from './wrapContextProvider' 4 | import { wrapReactMemo } from './wrapReactMemo' 5 | 6 | export type Renderer

    = { 7 | render: (props: P) => any 8 | tryToHandleError: ( 9 | err: Error 10 | ) => { handled: false } | { handled: true; result: any } 11 | } 12 | 13 | type GetRenderMethod =

    ( 14 | t: any, 15 | onContextChange: OnContextChange 16 | ) => Renderer

    17 | 18 | type OnContextChange = (t: any, v: any) => void 19 | 20 | type ComponentRenderType = 21 | | 'memo' 22 | | 'forwardRef' 23 | | 'classComponent' 24 | | 'contextProvider' 25 | | 'contextConsumer' 26 | | 'functional' 27 | 28 | const renderMethods: Record< 29 | ComponentRenderType, 30 | ( 31 | t: any, 32 | onContextChange: OnContextChange, 33 | grm: GetRenderMethod 34 | ) => Renderer 35 | > = { 36 | memo: wrapReactMemo, 37 | forwardRef: (t) => ({ 38 | render: t.render as any, 39 | tryToHandleError: () => ({ handled: false }), 40 | }), 41 | classComponent: wrapClassComponent, 42 | contextProvider: wrapContextProvider, 43 | contextConsumer: wrapContextConsumer, 44 | functional: (t) => ({ 45 | render: t as any, 46 | tryToHandleError: () => ({ handled: false }), 47 | }), 48 | } 49 | 50 | export const categorizeComponent = (t: any): ComponentRenderType => { 51 | let proto = t.prototype 52 | let type = t['$$typeof'] 53 | 54 | if (type?.toString() === 'Symbol(react.memo)') return 'memo' 55 | if (type?.toString() === 'Symbol(react.forward_ref)') return 'forwardRef' 56 | 57 | if (proto?.isReactComponent) { 58 | return 'classComponent' 59 | } 60 | 61 | if (t._context) { 62 | return t._context.Consumer === t ? 'contextConsumer' : 'contextProvider' 63 | } 64 | 65 | return 'functional' 66 | } 67 | 68 | export const getRenderMethod: GetRenderMethod = (t, onContextChange) => { 69 | const type = categorizeComponent(t) 70 | const method = renderMethods[type] 71 | return method(t, onContextChange, getRenderMethod) 72 | } 73 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/wrapClassComponent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { Renderer } from './renderMethod' 3 | 4 | export const wrapClassComponent =

    (t: { 5 | new (props: P): React.Component 6 | getDerivedStateFromError?: any 7 | }): Renderer

    => { 8 | let first = true 9 | let lastResult: any = null 10 | 11 | let prevProps: P | null = null 12 | let prevState: any | null = null 13 | let setStateCallbacks: Function[] = [] 14 | 15 | let updateComponentState: Function = () => {} 16 | 17 | let instance: any 18 | 19 | const render = (props: P) => { 20 | instance = instance || (new t(props) as any) 21 | const [componentState, setComponentState] = useState(instance.state) 22 | updateComponentState = setComponentState 23 | 24 | instance.setState = (s: any, cb: Function) => { 25 | if (cb) setStateCallbacks.push(cb) 26 | if (typeof s === 'function') { 27 | setComponentState(s(instance.state)) 28 | } else { 29 | const nextState = { ...componentState, ...s } 30 | setComponentState(nextState) 31 | } 32 | } 33 | 34 | const shouldRender = 35 | !!instance.shouldComponentUpdate && !first 36 | ? instance.shouldComponentUpdate(props, componentState) 37 | : true 38 | 39 | if (shouldRender) { 40 | prevProps = instance.props 41 | prevState = instance.state 42 | } 43 | 44 | useEffect(() => { 45 | if (instance.componentDidMount) { 46 | instance.componentDidMount() 47 | } 48 | return () => { 49 | if (instance.componentWillUnmount) { 50 | instance.componentWillUnmount() 51 | } 52 | } 53 | }, []) 54 | 55 | instance.props = props 56 | instance.state = componentState 57 | 58 | if (shouldRender) { 59 | let snapshot = undefined 60 | if (instance.getSnapshotBeforeUpdate && !first) { 61 | snapshot = instance.getSnapshotBeforeUpdate(prevProps, prevState) 62 | } 63 | 64 | lastResult = instance.render() 65 | 66 | if (instance.componentDidUpdate && !first) 67 | instance.componentDidUpdate(prevProps, prevState, snapshot) 68 | 69 | while (setStateCallbacks.length) setStateCallbacks.shift()() 70 | } 71 | 72 | first = false 73 | return lastResult 74 | } 75 | return { 76 | render, 77 | tryToHandleError: (error: Error) => { 78 | if ( 79 | !instance || 80 | !(instance.componentDidCatch || t.getDerivedStateFromError) 81 | ) 82 | return { handled: false } 83 | 84 | if (t.getDerivedStateFromError) { 85 | const derived = t.getDerivedStateFromError(error) 86 | updateComponentState(derived) 87 | } 88 | 89 | if (instance.componentDidCatch) { 90 | instance.componentDidCatch(error, {} as any) 91 | } 92 | 93 | return { handled: true, result: lastResult } 94 | }, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/wrapContextConsumer.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | export const wrapContextConsumer = (t: any) => ({ 4 | render: ({ children }: { children: any }) => { 5 | const value = useContext(t._context) 6 | 7 | if (typeof children === 'function') return children(value) 8 | return null 9 | }, 10 | tryToHandleError: () => ({ handled: false } as const), 11 | }) 12 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/wrapContextProvider.ts: -------------------------------------------------------------------------------- 1 | type OnContextChange = (t: any, v: any) => void 2 | 3 | export const wrapContextProvider = ( 4 | t: any, 5 | onContextChange: OnContextChange 6 | ) => { 7 | let value: any = t._context._currentValue 8 | return { 9 | render: (props: any) => { 10 | if (value !== props.value) { 11 | value = props.value 12 | onContextChange(t._context, props.value) 13 | } 14 | return props.children 15 | }, 16 | tryToHandleError: () => ({ handled: false } as const), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/isolatedRenderer/wrapReactMemo.ts: -------------------------------------------------------------------------------- 1 | const propsAreEqual = (p1 = {}, p2 = {}) => { 2 | const k1 = Object.keys(p1) 3 | const k2 = Object.keys(p2) 4 | if (k1.length !== k2.length) return false 5 | for (let k of k1) { 6 | if (p2[k] !== p1[k]) return false 7 | } 8 | 9 | return true 10 | } 11 | 12 | export const wrapReactMemo = ( 13 | t: any, 14 | onContextChange: any, 15 | getRenderMethod: any 16 | ) => { 17 | let lastProps = null 18 | let cachedResult = null 19 | let compare = t.compare || propsAreEqual 20 | 21 | const renderMethod = getRenderMethod(t.type, onContextChange) 22 | 23 | return { 24 | render: (props: any) => { 25 | if (cachedResult && compare(props, lastProps)) return cachedResult 26 | lastProps = props 27 | 28 | cachedResult = renderMethod.render(props) 29 | 30 | return cachedResult 31 | }, 32 | tryToHandleError: () => ({ handled: false } as const), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeMatcher.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './types/Selector' 2 | import { TreeNode } from './types/TreeNode' 3 | 4 | const idMatcher = (spec: string): NodeMatcher => { 5 | const [tag, id] = spec.split('#') 6 | return (node) => (!tag || tag === node.name) && id === node.props.id 7 | } 8 | 9 | const classOrExactMatcher = (spec: string): NodeMatcher => { 10 | const [tag, className] = spec.split('.') 11 | return (node) => 12 | ((!tag || tag === node.name) && 13 | (node.props.className || '').split(' ').includes(className)) || 14 | spec === node.name 15 | } 16 | 17 | const propMatcher = (spec: string): NodeMatcher => { 18 | const [name, rest] = spec.split('[') 19 | const [key, value] = rest.split(']')[0].split('=') 20 | return (node) => (!name || name === node.name) && node.props[key] === value 21 | } 22 | 23 | const nameMatcher = (spec: Selector): NodeMatcher => { 24 | return (node) => node.type === spec || node.name === spec 25 | } 26 | 27 | export const nodeMatcher = (spec: Selector | null | undefined): NodeMatcher => { 28 | if (!spec || spec === '*') return () => true 29 | if (typeof spec === 'string') { 30 | if (spec.includes('#')) { 31 | return idMatcher(spec) 32 | } 33 | 34 | if (/\[.+\]/.test(spec)) { 35 | return propMatcher(spec) 36 | } 37 | 38 | if (spec.includes('.')) { 39 | return classOrExactMatcher(spec) 40 | } 41 | } 42 | 43 | return nameMatcher(spec) 44 | } 45 | 46 | export default nodeMatcher 47 | 48 | export type NodeMatcher = (node: TreeNode) => boolean 49 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/context.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../types/TreeNode' 2 | 3 | export const doSetContext = (type: any, value: any, node: TreeNode) => { 4 | if (node.nodeType === 'isolated') { 5 | node.componentInstance!.setContext(type, value) 6 | } else { 7 | node.children?.forEach((child) => { 8 | doSetContext(type, value, child) 9 | }) 10 | } 11 | 12 | return node 13 | } 14 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/debug.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../types' 2 | 3 | const debugType = (type: any) => { 4 | if (typeof type === 'function') return { type: type.name } 5 | if (type._context) { 6 | return { 7 | type: 8 | type._context.Consumer === type 9 | ? `context consumer` 10 | : `context provider`, 11 | value: type._context._currentValue, 12 | } 13 | } 14 | return { type } 15 | } 16 | 17 | export const doDebug = (node: TreeNode, indent = 0) => { 18 | let lines = [] as string[] 19 | const space = indent 20 | ? Array(indent - 1) 21 | .fill(' ') 22 | .join('') + ' -> ' 23 | : '' 24 | let pushLine = (s: string) => lines.push(`${space}${s}`) 25 | 26 | const { children, componentInstance, type, ...rest } = node 27 | 28 | pushLine(JSON.stringify({ ...rest, ...debugType(type) })) 29 | 30 | if (node.nodeType === 'isolated') { 31 | lines = lines.concat(doDebug(componentInstance!.tree().root(), indent + 1)) 32 | } else { 33 | node.children.forEach((child) => { 34 | lines = lines.concat(doDebug(child, indent + 1)) 35 | }) 36 | } 37 | 38 | return lines.join('\n') 39 | } 40 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/index.ts: -------------------------------------------------------------------------------- 1 | import { parse } from './parse' 2 | import nodeMatcher, { NodeMatcher } from '../nodeMatcher' 3 | import { TreeNode } from '../types/TreeNode' 4 | import { Selector } from '../types/Selector' 5 | import { IsolatedRenderer } from '../isolatedRenderer' 6 | import { doInline } from './inline' 7 | import { reconcile } from './reconcile' 8 | import { doSetContext } from './context' 9 | import { doDebug } from './debug' 10 | 11 | const allNodes = (e: TreeNode) => 12 | [e].concat(e.children.map(allNodes).reduce((a, b) => a.concat(b), [])) 13 | 14 | type TreeSource = any /* React.ReactElement */ 15 | 16 | const describeSelector = (selector?: Selector) => { 17 | if (!selector) return '*' 18 | if (typeof selector === 'string') return selector 19 | if (selector.displayName) return selector.displayName 20 | if (typeof selector.name === 'string') return selector.name 21 | return `${selector}` 22 | } 23 | 24 | const findInvalidNodePaths = (node: TreeNode, path: string[] = []) => { 25 | if (node.nodeType === 'invalid') return [[...path, node.name]] 26 | let invalidChildPaths = [] 27 | for (const child of node.children) 28 | invalidChildPaths = [ 29 | ...invalidChildPaths, 30 | ...findInvalidNodePaths(child, [...path, node.name]), 31 | ] 32 | return invalidChildPaths 33 | } 34 | 35 | export const nodeTree = ( 36 | top: TreeSource, 37 | getRenderer: () => IsolatedRenderer, 38 | shouldInline: NodeMatcher 39 | ) => { 40 | let root = doInline(getRenderer, shouldInline, parse(top) as TreeNode) 41 | 42 | const filter = (predicate: (node: TreeNode) => boolean) => 43 | allNodes(root).filter(predicate) 44 | const findAll = (selector?: Selector) => filter(nodeMatcher(selector)) 45 | 46 | return { 47 | root: () => root, 48 | filter, 49 | exists: (selector?: Selector) => findAll(selector).length > 0, 50 | findAll, 51 | findOne: (selector?: Selector) => { 52 | const found = findAll(selector) 53 | 54 | if (found.length === 0) 55 | throw new Error( 56 | `Could not find element matching ${describeSelector( 57 | selector 58 | )} in ${root.toString()}` 59 | ) 60 | if (found.length > 1) 61 | throw new Error( 62 | `Expected one element matching ${describeSelector( 63 | selector 64 | )} but found ${found.length} elements in ${root.toString()}` 65 | ) 66 | return found[0] 67 | }, 68 | toString: () => root.toString(), 69 | content: () => root.content(), 70 | inlineAll: () => { 71 | root = doInline(getRenderer, shouldInline, root) 72 | }, 73 | setContext: (t, v) => { 74 | root = doSetContext(t, v, root) 75 | }, 76 | update: (next: TreeSource) => { 77 | root = doInline(getRenderer, shouldInline, reconcile(root, parse(next))) 78 | }, 79 | debug: () => doDebug(root), 80 | invalidNodePaths: () => findInvalidNodePaths(root, []), 81 | } 82 | } 83 | 84 | export type NodeTree = ReturnType 85 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/inline.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedRenderer } from '../isolatedRenderer' 2 | import { NodeMatcher } from '../nodeMatcher' 3 | import { TreeNode } from '../types' 4 | import { parseIsolated } from './parse' 5 | 6 | const inlineNode = (renderer: IsolatedRenderer, node: TreeNode) => { 7 | const isolated = renderer.render(node.type as any, node.props) 8 | return parseIsolated(isolated, node.type as any, node.key) 9 | } 10 | 11 | export const doInline = ( 12 | getRenderer: () => IsolatedRenderer, 13 | shouldInline: NodeMatcher, 14 | node: TreeNode 15 | ) => { 16 | if (node.nodeType === 'react' && shouldInline(node)) { 17 | return inlineNode(getRenderer(), node) 18 | } else if (node.nodeType === 'isolated') { 19 | node.componentInstance!.tree().inlineAll() 20 | } else { 21 | node.children.forEach((child, i) => { 22 | if (child.nodeType === 'react' && shouldInline(child)) { 23 | child = inlineNode(getRenderer(), child) 24 | node.children[i] = child 25 | } 26 | 27 | doInline(getRenderer, shouldInline, child) 28 | }) 29 | } 30 | 31 | return node 32 | } 33 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/common.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | 3 | export const displayName = (type: any): string => { 4 | if (typeof type === 'string' || typeof type === 'number') return '' + type 5 | 6 | return type.displayName || type.name 7 | } 8 | 9 | export const formatChildren = (children: any[]) => 10 | children.map((c: TreeNode) => c.toString()).join('') 11 | 12 | const formatPropValue = (v: any) => { 13 | if (typeof v === 'string') return `"${v}"` 14 | return `{${v}}` 15 | } 16 | 17 | const formatProps = (props: any) => { 18 | const keys = Object.keys(props) 19 | .filter((k) => k !== 'children') 20 | .sort() 21 | if (keys.length === 0) return '' 22 | return ` ${keys.map((k) => `${k}=${formatPropValue(props[k])}`).join(' ')}` 23 | } 24 | 25 | export const componentToString = ( 26 | value: any, 27 | children: TreeNode[], 28 | props: any 29 | ) => { 30 | const formattedProps = formatProps(props) 31 | const formattedChildren = formatChildren(children) 32 | const name = displayName(value) 33 | return `<${name}${formattedProps}${ 34 | children.length ? `>${formattedChildren}` : ` />` 35 | }` 36 | } 37 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/fragmentNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | import { formatChildren } from './common' 3 | 4 | export const fragmentNode = (children: TreeNode[]): TreeNode => ({ 5 | nodeType: 'fragment', 6 | type: 'fragment', 7 | children, 8 | name: '', 9 | props: {}, 10 | content: () => formatChildren(children), 11 | toString: () => formatChildren(children), 12 | }) 13 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/functionNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | 3 | export const functionNode = (value: Function): TreeNode => ({ 4 | nodeType: 'function', 5 | type: value, 6 | children: [], 7 | props: {}, 8 | name: '', 9 | content: () => `[Function]`, 10 | toString: () => `[Function]`, 11 | }) 12 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/htmlNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | import { componentToString, formatChildren } from './common' 3 | 4 | export const htmlNode = ( 5 | tag: string, 6 | props: any, 7 | children: TreeNode[] 8 | ): TreeNode => ({ 9 | nodeType: 'html', 10 | type: tag, 11 | name: tag, 12 | children, 13 | props, 14 | content: () => formatChildren(children), 15 | toString: () => componentToString(tag, children, props), 16 | }) 17 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/index.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | export { TreeNode } 3 | 4 | export { fragmentNode } from './fragmentNode' 5 | export { functionNode } from './functionNode' 6 | export { htmlNode } from './htmlNode' 7 | export { invalidNode } from './invalidNode' 8 | export { isolatedNode } from './isolatedNode' 9 | export { nothingNode } from './nothingNode' 10 | export { portalNode } from './portalNode' 11 | export { reactNode } from './reactNode' 12 | export { valueNode } from './valueNode' 13 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/invalidNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | export const invalidNode = (type: string): TreeNode => ({ 3 | nodeType: 'invalid', 4 | type, 5 | children: [], 6 | name: type, 7 | props: {}, 8 | content: () => `(INVALID: ${type})`, 9 | toString: () => `(INVALID: ${type})`, 10 | }) 11 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/isolatedNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | import { ComponentInstance } from '../../types/ComponentInstance' 3 | import { RenderableComponent } from '../../types/RenderableComponent' 4 | import { formatChildren } from './common' 5 | 6 | export const isolatedNode = ( 7 | instance: ComponentInstance, 8 | componentType: RenderableComponent 9 | ): TreeNode => ({ 10 | nodeType: 'isolated', 11 | type: componentType, 12 | componentInstance: instance, 13 | get children() { 14 | return [instance.tree().root()] 15 | }, 16 | name: '', 17 | props: {}, 18 | content: () => formatChildren([instance.tree().root()]), 19 | toString: () => formatChildren([instance.tree().root()]), 20 | }) 21 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/nothingNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | export const nothingNode = (type: string): TreeNode => ({ 3 | nodeType: 'nothing', 4 | type, 5 | name: '', 6 | children: [], 7 | props: {}, 8 | content: () => null, 9 | toString: () => '', 10 | }) 11 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/portalNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | import { formatChildren } from './common' 3 | 4 | // for now, just render children and support inlining etc. 5 | export const portalNode = (children: TreeNode[]): TreeNode => ({ 6 | nodeType: 'portal', 7 | type: 'portal', 8 | children, 9 | name: '', 10 | props: {}, 11 | content: () => formatChildren(children), 12 | toString: () => formatChildren(children), 13 | }) 14 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/reactNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | import { componentToString, displayName, formatChildren } from './common' 3 | 4 | export const reactNode = ( 5 | fc: React.FC, 6 | props: any, 7 | children: TreeNode[] 8 | ): TreeNode => ({ 9 | nodeType: 'react', 10 | type: fc, 11 | name: displayName(fc), 12 | children, 13 | props, 14 | content: () => formatChildren(children), 15 | toString: () => componentToString(fc, children, props), 16 | }) 17 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/nodes/valueNode.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../../types' 2 | 3 | export const valueNode = (value: string | number): TreeNode => ({ 4 | nodeType: typeof value as any, 5 | type: '' + value, 6 | children: [], 7 | props: {}, 8 | name: '', 9 | content: () => ('' + value) as string, 10 | toString: () => '' + value, 11 | }) 12 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/parse.ts: -------------------------------------------------------------------------------- 1 | import { ComponentNode } from '../types' 2 | import { InputNode } from '../types/InputNode' 3 | import { TreeNode } from '../types/TreeNode' 4 | import { Selector } from '../types/Selector' 5 | import { 6 | fragmentNode, 7 | functionNode, 8 | htmlNode, 9 | invalidNode, 10 | isolatedNode, 11 | nothingNode, 12 | portalNode, 13 | reactNode, 14 | valueNode, 15 | } from './nodes' 16 | 17 | import nodeMatcher from '../nodeMatcher' 18 | import { ComponentInstance } from '../types/ComponentInstance' 19 | import { RenderableComponent } from '../types/RenderableComponent' 20 | type NodePredicate = (node: TreeNode) => boolean 21 | 22 | const normalizeChildren = (children: any) => { 23 | if (typeof children === 'undefined') return [] 24 | if (Array.isArray(children)) return children 25 | return [children] 26 | } 27 | 28 | const isFragment = (node: InputNode) => 29 | node.type.toString() === 'Symbol(react.fragment)' 30 | 31 | const parseChildren = (children: InputNode[]) => 32 | normalizeChildren(children).map(parse) 33 | 34 | const parseRawNode = (node: InputNode): TreeNode => { 35 | if (node === null) return nothingNode('null') 36 | if (typeof node === 'undefined') return nothingNode('undefined') 37 | if (typeof node === 'function') return functionNode(node) 38 | 39 | // for now, treat array as fragment 40 | if (Array.isArray(node)) return fragmentNode(parseChildren(node)) 41 | 42 | if (typeof node === 'string' || typeof node === 'number') 43 | return valueNode(node) 44 | 45 | if (typeof node === 'boolean') return nothingNode('' + node) 46 | 47 | const { children } = (node.props || node || {}) as any 48 | const props = node.props || {} 49 | 50 | const parsedChildren = parseChildren(children) 51 | 52 | if (node['$$typeof'].toString() === 'Symbol(react.portal)') { 53 | return portalNode(parsedChildren) 54 | } 55 | 56 | if (node.type === null) { 57 | return invalidNode('null') 58 | } 59 | 60 | if (typeof node.type === 'undefined') { 61 | return invalidNode('undefined') 62 | } 63 | 64 | if (isFragment(node)) return fragmentNode(parsedChildren) 65 | 66 | if (typeof node.type === 'string') 67 | return htmlNode(node.type, props, parsedChildren) 68 | 69 | return reactNode(node.type as any, props, parsedChildren) 70 | } 71 | 72 | const allChildren = (e: TreeNode) => 73 | e.children.map(allNodes).reduce((a, b) => a.concat(b), []) 74 | 75 | const allNodes = (e: TreeNode) => 76 | [e].concat(e.children.map(allNodes).reduce((a, b) => a.concat(b), [])) 77 | 78 | export const parseIsolated = ( 79 | component: ComponentInstance, 80 | componentType: RenderableComponent, 81 | key: string 82 | ) => { 83 | const isolated = isolatedNode(component, componentType) 84 | isolated.key = key 85 | return isolated 86 | } 87 | 88 | export const parse = (node: InputNode): ComponentNode => { 89 | const parsed = parseRawNode(node) 90 | if (node) parsed.key = '' + (node.key || '') 91 | 92 | const filter = (predicate: NodePredicate) => 93 | allChildren(parsed).filter(predicate) 94 | const findAll = (selector?: Selector) => filter(nodeMatcher(selector)) 95 | 96 | return { 97 | ...parsed, 98 | exists: (selector?: Selector) => findAll(selector).length > 0, 99 | findAll, 100 | findOne: (selector?: Selector) => { 101 | const found = findAll(selector) 102 | if (found.length === 0) 103 | throw new Error(`Could not find element matching ${selector}`) 104 | if (found.length > 1) 105 | throw new Error( 106 | `Expected one element matching ${selector} but found ${found.length}` 107 | ) 108 | return found[0] 109 | }, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/reconcile.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { describe, test } from 'mocha' 3 | import { nodeTree } from '.' 4 | import { isolatedRenderer } from '../isolatedRenderer' 5 | import { RenderContext } from '../isolatedRenderer/renderContext' 6 | import assert from 'node:assert' 7 | 8 | const fakeRenderContext = (): RenderContext => ({ 9 | contexts: [], 10 | addInlinedSelector: () => {}, 11 | shouldInline: () => true, 12 | withContext: fakeRenderContext, 13 | copy: fakeRenderContext, 14 | }) 15 | 16 | const renderer = isolatedRenderer(fakeRenderContext()) 17 | 18 | const testNodeTree = (elements: any, shouldInline: boolean) => 19 | nodeTree( 20 | elements, 21 | () => renderer, 22 | () => shouldInline 23 | ) 24 | 25 | describe('reconcile', () => { 26 | test('handles html reconciliaion', () => { 27 | const Child = () =>

    child
    28 | const Parent = () => ( 29 |
    30 | 31 |
    32 | ) 33 | 34 | const tree = testNodeTree(Parent(), true) 35 | const before = tree.content() 36 | 37 | tree.update(Parent()) 38 | 39 | const after = tree.content() 40 | assert.strictEqual(after, before) 41 | }) 42 | 43 | test('handles fragment reconciliation', () => { 44 | const Child = () =>
    child
    45 | const Parent = () => ( 46 | <> 47 | 48 | 49 | ) 50 | 51 | const tree = testNodeTree(Parent(), true) 52 | assert.strictEqual(tree.content(), '
    child
    ') 53 | tree.update(Parent()) 54 | assert.strictEqual(tree.content(), '
    child
    ') 55 | }) 56 | 57 | test('handles react reconciliaion', () => { 58 | const Child = () =>
    child
    59 | const Parent = ({ children }: { children: React.ReactNode }) => ( 60 |
    {children}
    61 | ) 62 | 63 | const tree = testNodeTree( 64 | 65 | 66 | , 67 | false 68 | ) 69 | assert.strictEqual(tree.toString(), '') 70 | 71 | tree.update( 72 | 73 | 74 | 75 | 76 | ) 77 | 78 | assert.strictEqual(tree.toString(), '') 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/nodeTree/reconcile.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../types' 2 | import { fragmentNode, htmlNode } from './nodes' 3 | 4 | const matchChildren = ( 5 | previous: TreeNode[], 6 | next: TreeNode[] 7 | ): [TreeNode | null, TreeNode][] => { 8 | const getKey = (node: TreeNode, index: number) => { 9 | return node.key || `___${index}__` 10 | } 11 | const previousByKey: { [k: string]: TreeNode } = {} 12 | previous.forEach((node, i) => { 13 | const key = getKey(node, i) 14 | previousByKey[key] = node 15 | }) 16 | return next.map((node, i) => [previousByKey[getKey(node, i)] || null, node]) 17 | } 18 | 19 | const cleanup = (node: TreeNode) => { 20 | if (node.componentInstance) { 21 | node.componentInstance.cleanup() 22 | return 23 | } 24 | 25 | if (node.children) { 26 | node.children.forEach(cleanup) 27 | } 28 | } 29 | 30 | export const reconcile = ( 31 | previous: TreeNode | null, 32 | next: TreeNode 33 | ): TreeNode => { 34 | if (!previous) return next 35 | if (next.type !== previous.type) { 36 | cleanup(previous) 37 | return next 38 | } 39 | 40 | if (previous.nodeType === 'isolated') { 41 | previous.componentInstance!.setProps(next.props) 42 | return previous 43 | } 44 | 45 | const matchedChildren = matchChildren(previous.children, next.children) 46 | 47 | const matchedSet = new Set(matchedChildren.map(([p]) => p).filter((x) => !!x)) 48 | const unmatchedChildren = previous.children.filter((p) => !matchedSet.has(p)) 49 | 50 | for (let unmatchedChild of unmatchedChildren) { 51 | cleanup(unmatchedChild) 52 | } 53 | 54 | const reconciledChildren = matchedChildren.map(([p, n]) => reconcile(p, n)) 55 | 56 | if (previous.nodeType === 'html') { 57 | return htmlNode(next.type as string, next.props, reconciledChildren) 58 | } 59 | 60 | if (previous.nodeType === 'fragment') { 61 | return fragmentNode(reconciledChildren) 62 | } 63 | 64 | return { 65 | ...next, 66 | children: reconciledChildren, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/ComponentInstance.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Selector } from '../../isolateComponent' 3 | import { NodeTree } from '../nodeTree' 4 | 5 | export interface ComponentInstance

    { 6 | render: () => void 7 | cleanup: () => void 8 | setProps: (props: P) => void 9 | setContext: (type: React.Context, value: T) => void 10 | setRef: (index: number, value?: any) => void 11 | tree(): NodeTree 12 | mergeProps: (props: Partial

    ) => void 13 | inlineAll: (selector: Selector) => void 14 | waitForRender: () => Promise 15 | } 16 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/ComponentNode.ts: -------------------------------------------------------------------------------- 1 | import { QueryableNode } from './QueryableNode' 2 | import { TreeNode } from './TreeNode' 3 | 4 | /** 5 | * A node -- react component, html element, or string -- that was rendered by the component under test. 6 | * 7 | * Useful for getting access to props to assert that they have the correct value, or to trigger handlers like `onClick` or `onChange` to exercise the component. 8 | * 9 | * Also provides `toString()` and `content()` helpers for debugging. 10 | * 11 | * @interface 12 | * 13 | */ 14 | export interface ComponentNode 15 | extends TreeNode, 16 | QueryableNode {} 17 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/InputNode.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | export type InputNode = ReturnType 3 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/IsolateComponent.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedComponent } from './IsolatedComponent' 2 | 3 | /** 4 | * 5 | * This is the type of the main entry point: isolateComponent() 6 | * 7 | * It accepts a React Element that is a modern react component, usually created with JSX, and returns an 8 | * {@link IsolatedComponent} that provides methods for manipulating and checking 9 | * the results of rendering that component. 10 | * 11 | * @example Quick start 12 | * 13 | * ```js 14 | * import { isolateComponent } from 'isolated-components' 15 | * const Hello = (props) =>

    Hello {props.name}

    16 | * const component = isolateComponent() 17 | * 18 | * console.log(component.toString()) // => "

    Hello Zaphod

    " 19 | * ``` 20 | * 21 | * `isolateComponent` also exposes the method {@link IsolateComponent.withContext | isolateComponent.withContext} for setting context values for testing. 22 | * 23 | * 24 | * @returns IsolatedComponent - {@link IsolatedComponent} 25 | * @typeparam Props - Type of the component's props 26 | */ 27 | export interface IsolateComponent { 28 | /** 29 | * @hidden 30 | **/ 31 | ( 32 | componentElement: React.ReactElement 33 | ): IsolatedComponent 34 | 35 | /** 36 | * Set context values, for testing components that use `useContext`. 37 | * @param type The context type. This is the return value from React.createContext() 38 | * @param value The value of the context, to set for testing. 39 | * 40 | * Returns a new isolateComponent function that 41 | * will include the specifed context, making it 42 | * available to components that use `useContext`. 43 | * 44 | * @example Testing components that use useContext 45 | * 46 | * ```js 47 | * const NameContext = React.createContext('') 48 | * 49 | * const HelloWithContext = (props) => { 50 | * const name = useContext(NameContext) 51 | * return

    Hello {nameContext.value}

    52 | * } 53 | * 54 | * // To test this component, inject a context value as follows: 55 | * 56 | * const component = isolateComponent.withContext(NameContext, 'Trillian')() 57 | * console.log(component.toString()) // => "

    Hello Trillian

    " 58 | * 59 | * 60 | * `withContext` can be chained to set multiple context values 61 | * ``` 62 | */ 63 | withContext: ( 64 | type: React.Context, 65 | value: ContextType 66 | ) => IsolateComponent 67 | } 68 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/NodeContent.ts: -------------------------------------------------------------------------------- 1 | export interface NodeContent { 2 | /** 3 | * Returns the inner content of the node, formatted for debugging 4 | */ 5 | content(): string | null 6 | /** 7 | * Returns the outer content of the node (including its tag and props), formatted for debugging 8 | */ 9 | toString(): string 10 | } 11 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/QueryableNode.ts: -------------------------------------------------------------------------------- 1 | import { ComponentNode } from './ComponentNode' 2 | import { Selector } from './Selector' 3 | 4 | type SelectorProps = T extends React.ComponentType ? P : any 5 | 6 | export interface QueryableNode { 7 | /** 8 | * Find all child nodes that match. 9 | * 10 | * @param selector string or component 11 | * @returns - all matching nodes in the tree, or an empty array if none match 12 | * 13 | * @example Find all elements with matching tag name 14 | * 15 | * ```js 16 | * const MyList = () => ( 17 | *
      18 | *
    • Arthur
    • 19 | *
    • Trillian
    • 20 | *
    21 | * ) 22 | * const isolated = isolateComponent() 23 | * const listItems = isolated.findAll('li') 24 | * console.log(listItems[0].content()) // => 'Arthur' 25 | * console.log(listItems[1].content()) // => 'Trillian' 26 | * ``` 27 | * 28 | * See {@link Selector} docs for all supported selctor syntax. 29 | */ 30 | findAll(selector?: T): ComponentNode>[] 31 | /** 32 | * Find a single child node that matches, and throw if not found. 33 | * 34 | * @param selector string or component 35 | * @returns - the matching node 36 | * @throws - if no matching node found 37 | * 38 | * @example Find element by id 39 | * 40 | * ```js 41 | * const MyList = () => ( 42 | *
      43 | *
    • Arthur
    • 44 | *
    • Trillian
    • 45 | *
    46 | * ) 47 | * const isolated = isolateComponent() 48 | * const listItem1 = isolated.findOne('#1') 49 | * 50 | * console.log(listItem1.content()) // => 'Arthur' 51 | * 52 | * // this will throw an error because there are two matches 53 | * const listItem1 = isolated.findOne('li') 54 | * 55 | * // this will throw an error because there are no matches 56 | * const listItem1 = isolated.findOne('div') 57 | * ``` 58 | * See {@link Selector} docs for all supported selctor syntax. 59 | */ 60 | findOne(selector?: T): ComponentNode> 61 | 62 | /** 63 | * Check for the existence of any html elements or react components matching the selector. 64 | * 65 | * @example Find element by id 66 | * 67 | * ```js 68 | * const MyList = () => ( 69 | *
      70 | *
    • Arthur
    • 71 | *
    • Trillian
    • 72 | *
    73 | * ) 74 | * const isolated = isolateComponent() 75 | * 76 | * console.log(isolated.exists('li#1')) // => true 77 | * console.log(isolated.exists('span')) // => false 78 | * console.log(isolated.exists('ul')) // => true 79 | * console.log(isolated.exists('li')) // => true 80 | * ``` 81 | * 82 | * See {@link Selector} docs for all supported selctor syntax. 83 | */ 84 | exists(selector?: Selector): boolean 85 | } 86 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/RenderableComponent.ts: -------------------------------------------------------------------------------- 1 | export type RenderableComponent = 2 | | React.FC 3 | | React.ComponentClass 4 | | React.ClassicComponentClass 5 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/Selector.ts: -------------------------------------------------------------------------------- 1 | import { RenderableComponent } from './RenderableComponent' 2 | 3 | /** 4 | * Query for finding a child node of a component under test, used with the finder methods: `exists`, `findOne` and `findAll`. 5 | * 6 | * Use a string to find html nodes using the following syntax: 7 | * 8 | * Find by id: 9 | * 10 | * `div#awesome-id` and `#awesome-id` will find `
    ` 11 | * 12 | * Find by className: 13 | * 14 | * `span.cool` and `.cool` will each find ``m 15 | * 16 | * Find by a matching prop: 17 | * 18 | * `[data-test-id=foobar]` will find the react element or html element with a `data-test-id` prop with the value `foobar` 19 | * 20 | * 21 | * Find a react component: 22 | * 23 | * Use a component function or name to find react components. 24 | * 25 | * @category Querying 26 | * 27 | */ 28 | export type Selector = string | RenderableComponent 29 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/TreeNode.ts: -------------------------------------------------------------------------------- 1 | import { ComponentInstance } from './ComponentInstance' 2 | import { InputNode } from './InputNode' 3 | import { NodeContent } from './NodeContent' 4 | 5 | export interface TreeNode extends NodeContent { 6 | /** 7 | * The type of node: a react component, html, string or null. 8 | */ 9 | nodeType: 10 | | 'fragment' 11 | | 'function' 12 | | 'html' 13 | | 'invalid' 14 | | 'isolated' 15 | | 'nothing' 16 | | 'number' 17 | | 'portal' 18 | | 'react' 19 | | 'string' 20 | /** 21 | * For html elements, the tag name 22 | * For a react FC, the display name 23 | * otherwise empty 24 | */ 25 | name: string 26 | componentInstance?: ComponentInstance 27 | /** 28 | * The `type` as returned from React.createElement 29 | * For a react FC, the component function. 30 | * For an html node, the tag name. 31 | * For a string, the string. 32 | */ 33 | type: InputNode['type'] | Function 34 | key?: string 35 | /** 36 | * Children, if present, or else an empty array 37 | * @hidden 38 | */ 39 | children: TreeNode[] 40 | /** 41 | * React or html props, excluding children. 42 | */ 43 | props: Props 44 | } 45 | -------------------------------------------------------------------------------- /isolate-react/src/isolateComponent/types/index.ts: -------------------------------------------------------------------------------- 1 | export { IsolatedComponent } from './IsolatedComponent' 2 | export { Selector } from './Selector' 3 | export { ComponentNode } from './ComponentNode' 4 | export { TreeNode } from './TreeNode' 5 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dirtyDependencies.ts: -------------------------------------------------------------------------------- 1 | type Deps = any[] | undefined 2 | 3 | const dirtyDependencies = (a: Deps, b: Deps) => { 4 | if (a === undefined || b === undefined) return true 5 | return a.some((value, i) => !Object.is(value, b[i])) 6 | } 7 | 8 | export default dirtyDependencies 9 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/effects.ts: -------------------------------------------------------------------------------- 1 | type Deps = any[] | undefined 2 | 3 | export const createEffectHandler = 4 | (effectSet: any) => (effect: () => (() => void) | undefined, deps: Deps) => { 5 | effectSet.nextEffect(effect, deps) 6 | } 7 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/index.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IsolatedHookState } from '../isolatedHookState' 3 | import { createUseState } from './useState' 4 | import { createMemoizer } from './memoize' 5 | import { createUseReducer } from './useReducer' 6 | import { createUseSyncExternalStore } from './useSyncExternalStore' 7 | import { createUseRef } from './useRef' 8 | import { createUseId } from './useId' 9 | import { createEffectHandler } from './effects' 10 | 11 | interface Dispatcher { 12 | useCallback: typeof React.useCallback 13 | useContext: typeof React.useContext 14 | useDebugValue: typeof React.useDebugValue 15 | useEffect: typeof React.useEffect 16 | useImperativeHandle: typeof React.useImperativeHandle 17 | useLayoutEffect: typeof React.useEffect 18 | useInsertionEffect: typeof React.useInsertionEffect 19 | useMemo: typeof React.useMemo 20 | useState: typeof React.useState 21 | useReducer: typeof React.useReducer 22 | useRef: typeof React.useRef 23 | useId: typeof React.useId 24 | useTransition: typeof React.useTransition 25 | useDeferredValue: typeof React.useDeferredValue 26 | useSyncExternalStore: typeof React.useSyncExternalStore 27 | } 28 | 29 | export const createIsolatedDispatcher = ( 30 | isolatedHookState: IsolatedHookState 31 | ): Dispatcher => { 32 | const memoize = createMemoizer(isolatedHookState) 33 | 34 | return { 35 | useMemo: ((fn: any, deps: any) => { 36 | return memoize('useMemo', fn, deps) 37 | }) as any, 38 | useCallback: ((fn: any, deps: any) => { 39 | return memoize('useCallback', () => fn, deps) 40 | }) as any, 41 | useDebugValue: () => {}, 42 | useDeferredValue: (value) => value, 43 | useImperativeHandle: () => {}, 44 | useState: createUseState(isolatedHookState) as any, 45 | useReducer: createUseReducer(isolatedHookState) as any, 46 | useEffect: createEffectHandler(isolatedHookState.effects), 47 | useLayoutEffect: createEffectHandler(isolatedHookState.layoutEffects), 48 | useInsertionEffect: createEffectHandler(isolatedHookState.insertionEffects), 49 | useContext: (type) => isolatedHookState.contextValue(type), 50 | useId: createUseId(isolatedHookState), 51 | useSyncExternalStore: createUseSyncExternalStore(isolatedHookState), 52 | useTransition: () => [ 53 | false, 54 | (fn) => { 55 | fn() 56 | }, 57 | ], 58 | useRef: createUseRef(isolatedHookState), 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/memoize.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedHookState } from '../isolatedHookState' 2 | import dirtyDependencies from '../dirtyDependencies' 3 | 4 | export const createMemoizer = 5 | (isolatedHookState: IsolatedHookState) => 6 | (type: 'useMemo' | 'useCallback', fn: Function, deps: any) => { 7 | const [state] = isolatedHookState.nextHookState({ 8 | type, 9 | create: () => ({ 10 | value: fn(), 11 | deps, 12 | }), 13 | }) 14 | if (dirtyDependencies(deps, state.value.deps)) { 15 | state.value.value = fn() 16 | state.value.deps = deps 17 | } 18 | return state.value.value 19 | } 20 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/useId.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedHookState } from '../isolatedHookState' 2 | 3 | let nextUseIdValue = 0 4 | 5 | const generateId = () => { 6 | nextUseIdValue++ 7 | return `useId-${nextUseIdValue}` 8 | } 9 | 10 | export const createUseId = (isolatedHookState: IsolatedHookState) => () => { 11 | const [state] = isolatedHookState.nextHookState({ 12 | type: 'useState', 13 | create: generateId, 14 | }) 15 | 16 | return state.value 17 | } 18 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/useReducer.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'react' 2 | import { IsolatedHookState } from '../isolatedHookState' 3 | 4 | export const createUseReducer = 5 | (isolatedHookState: IsolatedHookState) => 6 | ( 7 | reducer: (state: S, action: A) => S, 8 | initialState: S | (() => S) 9 | ): [state: S, dispatch: Dispatch] => { 10 | const factory: () => S = (typeof initialState === 'function' 11 | ? initialState 12 | : () => initialState) as unknown as () => S 13 | 14 | const [state, updateState] = isolatedHookState.nextHookState({ 15 | type: 'useReducer', 16 | create: factory, 17 | update: (current: S, action: A) => ({ 18 | value: reducer(current, action), 19 | }), 20 | }) 21 | 22 | return [state.value, updateState as any] 23 | } 24 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/useRef.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedHookState } from '../isolatedHookState' 2 | 3 | export const createUseRef = 4 | (isolatedHookState: IsolatedHookState) => (initialValue?: any) => { 5 | const [ref] = isolatedHookState.nextHookState({ 6 | type: 'useRef', 7 | create: () => ({ 8 | current: initialValue, 9 | }), 10 | }) 11 | return ref.value 12 | } 13 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/useState.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedHookState } from '../isolatedHookState' 2 | 3 | type SetState = React.Dispatch> 4 | 5 | export const createUseState = 6 | (isolatedHookState: IsolatedHookState) => 7 | (initialState: T | (() => T)): [state: T, setter: SetState] => { 8 | const factory: () => T = (typeof initialState === 'function' 9 | ? initialState 10 | : () => initialState) as unknown as () => T 11 | 12 | const [state, updateState] = isolatedHookState.nextHookState({ 13 | type: 'useState', 14 | create: factory, 15 | update: (current: T, next: T | React.SetStateAction) => { 16 | if (next instanceof Function) { 17 | return { value: next(current) } 18 | } 19 | return { value: next } 20 | }, 21 | }) 22 | 23 | return [state.value, updateState] 24 | } 25 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/dispatcher/useSyncExternalStore.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IsolatedHookState } from '../isolatedHookState' 3 | 4 | export const createUseSyncExternalStore = 5 | (isolatedHookState: IsolatedHookState): typeof React.useSyncExternalStore => 6 | (subscribe, getSnapshot, _getServerSnapshot) => { 7 | const [state] = isolatedHookState.nextHookState({ 8 | type: 'useSyncExternalStore', 9 | create: () => ({ 10 | getSnapshot, 11 | subscribe, 12 | unsubscribe: () => {}, 13 | value: getSnapshot(), 14 | }), 15 | update: (previous, next) => { 16 | return { value: { ...previous, value: next } } 17 | }, 18 | onCreated: (update, value) => { 19 | const updateState = () => { 20 | update(value.getSnapshot()) 21 | } 22 | value.unsubscribe = value.subscribe(updateState) 23 | }, 24 | cleanup: (value) => { 25 | value.unsubscribe() 26 | }, 27 | }) 28 | 29 | return state.value.value 30 | } 31 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/effectSet.ts: -------------------------------------------------------------------------------- 1 | import dirtyDependencies from './dirtyDependencies' 2 | type Effect = () => void | Function 3 | 4 | type EffectState = { 5 | effect: Effect 6 | deps?: any[] 7 | dirty: boolean 8 | cleanup?: Function | void 9 | } 10 | 11 | type Deps = any[] | undefined 12 | 13 | export const createEffectSet = () => { 14 | let effects: EffectState[] = [] 15 | let nextEffects: EffectState[] = [] 16 | 17 | const flush = () => { 18 | const dirtyEffects = nextEffects.filter((e) => e.dirty) 19 | dirtyEffects.forEach((e) => { 20 | if (e.cleanup) e.cleanup() 21 | e.cleanup = e.effect() 22 | }) 23 | 24 | effects = nextEffects 25 | } 26 | 27 | return { 28 | cleanup: () => { 29 | effects.forEach((effect) => { 30 | if (typeof effect.cleanup === 'function') { 31 | effect.cleanup() 32 | } 33 | }) 34 | }, 35 | flush, 36 | nextEffect: (effect: Effect, deps: Deps) => { 37 | const firstTime = !effects.length 38 | 39 | const nextEffect: EffectState = { effect, deps, dirty: firstTime } 40 | 41 | if (!firstTime) { 42 | const existing = effects.shift() 43 | nextEffect.dirty = dirtyDependencies(existing.deps, deps) 44 | nextEffect.cleanup = existing.cleanup 45 | } 46 | 47 | nextEffects.push(nextEffect) 48 | return 49 | }, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/hookContexts.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedHookContext } from './types/IsolatedHookOptions' 2 | 3 | export const createHookContexts = ( 4 | initialContexts: IsolatedHookContext[], 5 | onUpdated: () => void 6 | ) => { 7 | let usedContextTypes = new Set>() 8 | 9 | const context = new Map, any>() 10 | 11 | if (initialContexts) { 12 | initialContexts.forEach((c) => { 13 | context.set(c.type, c.value) 14 | }) 15 | } 16 | 17 | const contextValue = (type: React.Context) => 18 | context.has(type) ? context.get(type) : (type as any)._currentValue 19 | 20 | const setContext = (contextType: React.Context, value: any) => { 21 | if (contextValue(contextType) === value) return 22 | context.set(contextType, value) 23 | if (usedContextTypes.has(contextType)) { 24 | onUpdated() 25 | } 26 | } 27 | 28 | return { 29 | setContext, 30 | contextValue: (contextType: React.Context) => { 31 | usedContextTypes.add(contextType) 32 | return contextValue(contextType) 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/index.ts: -------------------------------------------------------------------------------- 1 | export { isolateHook } from './isolateHook' 2 | export { IsolatedHook } from './types/IsolatedHook' 3 | export { IsolatedHookOptions } from './types/IsolatedHookOptions' 4 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/isolateHook.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { createIsolatedDispatcher } from './dispatcher' 4 | import { createIsolatedHookState } from './isolatedHookState' 5 | import { IsolatedHook } from './types/IsolatedHook' 6 | import { IsolatedHookOptions } from './types/IsolatedHookOptions' 7 | import { IsolateHook } from './types/IsolateHook' 8 | 9 | const { ReactCurrentDispatcher } = (React as any) 10 | .__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 11 | 12 | const checkHookFunction = (fn: any) => { 13 | if (fn === null) { 14 | throw new Error('isolateHooks: Expected a hook function but got null') 15 | } 16 | if (fn === undefined) { 17 | throw new Error('isolateHooks: Expected a hook function but got undefined') 18 | } 19 | 20 | if (typeof fn !== 'function') { 21 | throw new Error( 22 | `isolateHooks: Expected a hook function but got ${typeof fn} (${fn})` 23 | ) 24 | } 25 | } 26 | 27 | /** 28 | * @category Entry Point 29 | * @returns IsolatedHook 30 | * @param hookInvocation The hook to isolate. 31 | * @param options Optional options, for specifying context values. 32 | */ 33 | export const isolateHook: IsolateHook = any>( 34 | hookInvocation: F, 35 | options: IsolatedHookOptions = {} 36 | ): IsolatedHook => { 37 | checkHookFunction(hookInvocation) 38 | 39 | const hookState = createIsolatedHookState(options) 40 | const dispatcher = createIsolatedDispatcher(hookState) 41 | let updateWaiters = [] as ((val: ReturnType) => void)[] 42 | 43 | let lastArgs: Parameters 44 | 45 | let lastResult: ReturnType 46 | 47 | const invoke = () => invokeHook(...(lastArgs || ([] as any))) 48 | 49 | const withPausedUpdates = (fn: () => void) => { 50 | hookState.onUpdated(() => {}) 51 | fn() 52 | hookState.onUpdated(invoke) 53 | } 54 | 55 | const withOverridenDispatch = (fn: () => void) => { 56 | const previousDispatcher = ReactCurrentDispatcher.current 57 | ReactCurrentDispatcher.current = dispatcher 58 | fn() 59 | 60 | ReactCurrentDispatcher.current = previousDispatcher 61 | } 62 | 63 | const flushUpdates = () => { 64 | updateWaiters.forEach((waiter) => waiter(lastResult)) 65 | updateWaiters = [] 66 | } 67 | 68 | const invokeHook = (...args: Parameters): ReturnType => { 69 | withPausedUpdates(() => { 70 | withOverridenDispatch(() => { 71 | hookState.invokeWhileDirty(() => { 72 | lastResult = hookInvocation(...args) 73 | }) 74 | }) 75 | }) 76 | 77 | lastArgs = args 78 | flushUpdates() 79 | 80 | return lastResult 81 | } 82 | 83 | const currentValue = () => lastResult 84 | 85 | const waitForUpdate = () => { 86 | return new Promise>((resolve) => { 87 | updateWaiters.push(resolve) 88 | }) 89 | } 90 | 91 | return Object.assign(invokeHook as any as F, { 92 | currentValue, 93 | cleanup: () => { 94 | hookState.cleanup() 95 | }, 96 | invoke, 97 | setRef: hookState.setRef, 98 | setContext: hookState.setContext, 99 | waitForUpdate, 100 | wrapUpdates: hookState.invokeWhileDirty, 101 | }) 102 | } 103 | 104 | export default isolateHook 105 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/isolatedHookState.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'mocha' 2 | import assert from 'node:assert' 3 | import createIsolatedHookState from './isolatedHookState' 4 | import { HookStateDef } from './updatableHookStates' 5 | 6 | const exampleHookStateOptions = { 7 | type: 'useMemo', 8 | create: () => ({}), 9 | } as HookStateDef 10 | 11 | describe('isolatedHookState', () => { 12 | describe('invokeWhileDirty', () => { 13 | test('state is stable', () => { 14 | let firstPassState: any 15 | const isolatedHookState = createIsolatedHookState({}) 16 | isolatedHookState.invokeWhileDirty(() => { 17 | firstPassState = isolatedHookState.nextHookState( 18 | exampleHookStateOptions 19 | ) 20 | }) 21 | 22 | isolatedHookState.invokeWhileDirty(() => { 23 | const secondPassState = isolatedHookState.nextHookState( 24 | exampleHookStateOptions 25 | ) 26 | assert.strictEqual(secondPassState, firstPassState) 27 | }) 28 | }) 29 | 30 | test('state is stable when error occurs', () => { 31 | let firstPassState: any 32 | const isolatedHookState = createIsolatedHookState({}) 33 | 34 | try { 35 | isolatedHookState.invokeWhileDirty(() => { 36 | firstPassState = isolatedHookState.nextHookState( 37 | exampleHookStateOptions 38 | ) 39 | throw new Error('test') 40 | }) 41 | } catch (e) {} 42 | 43 | isolatedHookState.invokeWhileDirty(() => { 44 | const secondPassState = isolatedHookState.nextHookState( 45 | exampleHookStateOptions 46 | ) 47 | assert.strictEqual(secondPassState, firstPassState) 48 | }) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/isolatedHookState.ts: -------------------------------------------------------------------------------- 1 | import { createEffectSet } from './effectSet' 2 | import { createHookContexts } from './hookContexts' 3 | import { createUpdatableHookStates } from './updatableHookStates' 4 | 5 | import { IsolatedHookOptions } from './types/IsolatedHookOptions' 6 | export type IsolatedHookState = ReturnType 7 | 8 | export const createIsolatedHookState = (options: IsolatedHookOptions) => { 9 | let onUpdated = () => {} 10 | 11 | const layoutEffects = createEffectSet() 12 | const effects = createEffectSet() 13 | const insertionEffects = createEffectSet() 14 | 15 | const updatableStates = createUpdatableHookStates() 16 | 17 | const contexts = createHookContexts(options?.context || [], () => onUpdated()) 18 | 19 | const endPass = () => { 20 | insertionEffects.flush() 21 | layoutEffects.flush() 22 | effects.flush() 23 | 24 | updatableStates.endPass() 25 | } 26 | 27 | const invokeWhileDirty = (fn: () => void) => { 28 | do { 29 | updatableStates.startPass() 30 | try { 31 | fn() 32 | } finally { 33 | endPass() 34 | } 35 | } while (updatableStates.dirty()) 36 | } 37 | 38 | return { 39 | layoutEffects, 40 | effects, 41 | insertionEffects, 42 | invokeWhileDirty, 43 | nextHookState: updatableStates.nextHookState, 44 | setContext: contexts.setContext, 45 | setRef: updatableStates.setRef, 46 | contextValue: contexts.contextValue, 47 | onUpdated: (handler: () => void) => { 48 | onUpdated = handler 49 | updatableStates.onUpdated(handler) 50 | }, 51 | cleanup: () => { 52 | insertionEffects.cleanup() 53 | layoutEffects.cleanup() 54 | effects.cleanup() 55 | updatableStates.cleanup() 56 | }, 57 | } 58 | } 59 | 60 | export default createIsolatedHookState 61 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/types/IsolateHook.ts: -------------------------------------------------------------------------------- 1 | import { IsolatedHook } from './IsolatedHook' 2 | import { IsolatedHookOptions } from './IsolatedHookOptions' 3 | 4 | export type IsolateHook = any>( 5 | hookInvocation: F, 6 | options?: IsolatedHookOptions 7 | ) => IsolatedHook 8 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/types/IsolatedHook.ts: -------------------------------------------------------------------------------- 1 | type Parameters = T extends (...args: infer T) => any ? T : never 2 | type WrappedFunction any> = ( 3 | ...args: Parameters 4 | ) => ReturnType 5 | 6 | export type IsolatedHookMethods any> = { 7 | cleanup: () => void 8 | /** 9 | * Get the value returned by the most recent hook invocation 10 | */ 11 | currentValue: () => ReturnType 12 | /** 13 | * Force hook to run 14 | */ 15 | invoke: WrappedFunction 16 | /** 17 | * Set the current value of a ref 18 | * @param index The zero-based index of the ref (zero for the first useRef, one for the second, etc.) 19 | * @param value Value to set. 20 | */ 21 | setRef: (index: number, value?: any) => void 22 | 23 | /** 24 | * Set the value of a react context used by the hook under test 25 | * @param contextType The type of the context 26 | * @param value Value to set. 27 | */ 28 | setContext: (contextType: React.Context, value: T) => void 29 | 30 | /** 31 | * Returns a promise that resolves when the hook has been updated 32 | */ 33 | waitForUpdate: () => Promise> 34 | 35 | wrapUpdates: (fn: () => void) => void 36 | } 37 | /** 38 | * A hook running in isolation. This is the return value from isolateHook. 39 | */ 40 | export type IsolatedHook any> = 41 | IsolatedHookMethods & F 42 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/types/IsolatedHookOptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A context value used for testing useContext 3 | */ 4 | export interface IsolatedHookContext { 5 | /** 6 | * The type of context. The return value of `React.CreateContext`. 7 | */ 8 | type: React.Context 9 | /** 10 | * Value for the context. 11 | */ 12 | value: any 13 | } 14 | 15 | /** 16 | * Options when isolating hook, passed as 2nd argument 17 | */ 18 | export interface IsolatedHookOptions { 19 | /** 20 | * An array of context values, useful when testing useContext 21 | */ 22 | context?: IsolatedHookContext[] 23 | } 24 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/updatableHookStates.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'mocha' 2 | import { createUpdatableHookStates } from './updatableHookStates' 3 | import assert from 'node:assert' 4 | 5 | describe('updatableHookStates', () => { 6 | test('nextHookState constructs initial state', () => { 7 | const states = createUpdatableHookStates() 8 | const [{ value }] = states.nextHookState({ 9 | type: 'useState', 10 | create: () => 42, 11 | }) 12 | assert.strictEqual(value, 42) 13 | }) 14 | 15 | test('update function is stable', () => { 16 | const states = createUpdatableHookStates() 17 | const [, first] = states.nextHookState({ 18 | type: 'useState', 19 | create: () => 42, 20 | }) 21 | states.endPass() 22 | 23 | const [, second] = states.nextHookState({ 24 | type: 'useState', 25 | create: () => 42, 26 | }) 27 | 28 | assert.strictEqual(first, second) 29 | }) 30 | 31 | test('update function is stable', () => { 32 | const states = createUpdatableHookStates() 33 | const [, first] = states.nextHookState({ 34 | type: 'useState', 35 | create: () => 42, 36 | }) 37 | states.endPass() 38 | 39 | const [, second] = states.nextHookState({ 40 | type: 'useState', 41 | create: () => 42, 42 | }) 43 | 44 | assert.strictEqual(first, second) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /isolate-react/src/isolateHook/updatableHookStates.ts: -------------------------------------------------------------------------------- 1 | export type StateType = 2 | | 'useRef' 3 | | 'useState' 4 | | 'useMemo' 5 | | 'useCallback' 6 | | 'useReducer' 7 | | 'useSyncExternalStore' 8 | 9 | export type HookState = [ 10 | { value: T; type: StateType; cleanup?: (value: T) => void }, 11 | (update: (previous: T) => T) => void 12 | ] 13 | 14 | export interface HookStateDef { 15 | type: StateType 16 | create: () => T 17 | 18 | update?: ( 19 | current: T, 20 | next: any 21 | ) => { 22 | value: T 23 | } 24 | 25 | cleanup?: (value: T) => void 26 | 27 | onCreated?: ( 28 | update: (getNextValue: (previous: T) => T) => void, 29 | value: T 30 | ) => void 31 | } 32 | 33 | /** 34 | * Updatable hook states hold a value that can be updated. 35 | * When it is updated, they can effect a re-invocation of the containing hook. 36 | * 37 | * This is the backing storage for: 38 | * - useState 39 | * - useRef 40 | * - useSyncExternalStore 41 | * - useReducer 42 | * - useMemo 43 | * - useCalback 44 | * 45 | * Within a hook invocation, they must have the same order. 46 | */ 47 | export const createUpdatableHookStates = () => { 48 | let inProgress = false 49 | let dirty = false 50 | let first = true 51 | 52 | let hookStates: HookState[] = [] 53 | let nextHookStates: HookState[] = [] 54 | let pendingStateUpdates = [] as (() => void)[] 55 | 56 | let onUpdated = () => {} 57 | 58 | const executeOutsideRenderPass = (fn: () => void) => { 59 | if (inProgress) { 60 | pendingStateUpdates.push(fn) 61 | } else { 62 | fn() 63 | } 64 | } 65 | 66 | const addHookState = ( 67 | type: StateType, 68 | value: T, 69 | updateValue?: any, 70 | onCreated?: any, 71 | cleanup?: (value: T) => void 72 | ): HookState => { 73 | let newState = { value, type, cleanup, pendingValue: value } 74 | 75 | const updater = updateValue 76 | ? (next: any) => { 77 | newState.pendingValue = updateValue(newState.pendingValue, next).value 78 | 79 | executeOutsideRenderPass(() => { 80 | newState.value = newState.pendingValue 81 | 82 | dirty = true 83 | onUpdated() 84 | }) 85 | } 86 | : () => { 87 | throw new Error(`Could not update ${type}`) 88 | } 89 | 90 | const state = [newState, updater] as HookState 91 | 92 | if (onCreated) onCreated(updater, newState.value) 93 | 94 | nextHookStates.push(state) 95 | 96 | return state 97 | } 98 | 99 | const nextHookState = ({ 100 | type, 101 | create, 102 | update, 103 | onCreated, 104 | cleanup, 105 | }: HookStateDef): HookState => { 106 | if (first) return addHookState(type, create(), update, onCreated, cleanup) 107 | let state = hookStates.shift() 108 | nextHookStates.push(state) 109 | return state 110 | } 111 | 112 | const startPass = () => { 113 | inProgress = true 114 | } 115 | 116 | const flushStateUpdates = () => { 117 | for (let update of pendingStateUpdates) { 118 | update() 119 | } 120 | pendingStateUpdates = [] 121 | } 122 | 123 | const endPass = () => { 124 | inProgress = false 125 | dirty = false 126 | first = false 127 | 128 | flushStateUpdates() 129 | 130 | hookStates = nextHookStates 131 | } 132 | 133 | const setRef = (index: number, value: any) => { 134 | const refs = hookStates.filter(([s]) => s.type === 'useRef') 135 | refs[index][0].value.current = value 136 | } 137 | 138 | return { 139 | startPass, 140 | endPass, 141 | nextHookState, 142 | setRef, 143 | dirty: () => dirty, 144 | onUpdated: (handler: () => void) => { 145 | onUpdated = handler 146 | }, 147 | cleanup: () => { 148 | for (let [hookState] of hookStates) { 149 | if (hookState.cleanup) hookState.cleanup(hookState.value) 150 | } 151 | }, 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /isolate-react/test/babel-register.js: -------------------------------------------------------------------------------- 1 | const register = require('@babel/register').default 2 | require('core-js/stable') 3 | require('regenerator-runtime/runtime') 4 | 5 | register({ 6 | plugins: ['@babel/plugin-proposal-class-properties'], 7 | presets: [ 8 | '@babel/preset-env', 9 | [ 10 | '@babel/preset-typescript', 11 | { isTSX: true, allExtensions: true, onlyRemoveTypeImports: true }, 12 | ], 13 | '@babel/preset-react', 14 | ], 15 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 16 | }) 17 | -------------------------------------------------------------------------------- /isolate-react/test/isolateComponent/children.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha' 2 | import React from 'react' 3 | import assert from 'node:assert' 4 | import { isolateComponent } from '../../src/isolateComponent' 5 | 6 | describe('children', () => { 7 | const ToggleChildren = (props: { 8 | show: boolean 9 | children: React.ReactNode 10 | }) => { 11 | if (props.show) return <>{props.children} 12 | return <> 13 | } 14 | 15 | it('handles children', () => { 16 | const isolated = isolateComponent( 17 | 18 |
    19 | 20 | ) 21 | assert.strictEqual(isolated.exists('div'), true) 22 | }) 23 | 24 | it('handles untoggling children', () => { 25 | const isolated = isolateComponent( 26 | 27 |
    28 | 29 | ) 30 | assert.strictEqual(isolated.exists('div'), true) 31 | isolated.mergeProps({ show: false }) 32 | assert.strictEqual(isolated.exists('div'), false) 33 | }) 34 | 35 | it('handles toggling children', () => { 36 | const isolated = isolateComponent( 37 | 38 |
    39 | 40 | ) 41 | 42 | isolated.mergeProps({ show: true }) 43 | assert.strictEqual(isolated.exists('div'), true) 44 | }) 45 | 46 | describe('inlined', () => { 47 | const Wrapper = (props: { show: boolean; children: React.ReactNode }) => ( 48 | {props.children} 49 | ) 50 | 51 | it('handles inlined component with children', () => { 52 | const isolated = isolateComponent( 53 | 54 |
    55 | 56 | ) 57 | isolated.inline(ToggleChildren) 58 | assert.strictEqual(isolated.exists('div'), true) 59 | }) 60 | 61 | it('passes down children', () => { 62 | const isolated = isolateComponent( 63 | 64 |
    65 | 66 | ) 67 | const inner = isolated.findOne(ToggleChildren) 68 | assert.strictEqual(inner.children.length, 1) 69 | }) 70 | 71 | it('handles cascading props down to inlined component with children', () => { 72 | const isolated = isolateComponent( 73 | 74 |
    75 | 76 | ) 77 | isolated.inline(ToggleChildren) 78 | assert.strictEqual(isolated.exists('div'), false) 79 | isolated.mergeProps({ show: true }) 80 | assert.strictEqual(isolated.exists('div'), true) 81 | isolated.mergeProps({ show: false }) 82 | assert.strictEqual(isolated.exists('div'), false) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /isolate-react/test/isolateComponent/content.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha' 2 | import React from 'react' 3 | import { isolateComponent } from '../../src/isolateComponent' 4 | import assert from 'node:assert' 5 | 6 | describe('getting component content', () => { 7 | const Answer = ({ answer }: { answer: number }) => ( 8 | The answer is {answer} 9 | ) 10 | 11 | it('content() returns inner content', () => { 12 | const answer = isolateComponent() 13 | assert.strictEqual(answer.content(), 'The answer is 42') 14 | }) 15 | 16 | it('toString() returns outer content', () => { 17 | const answer = isolateComponent() 18 | assert.strictEqual(answer.toString(), 'The answer is 42') 19 | }) 20 | 21 | it('handles child text', () => { 22 | const Parent = (props: { children: React.ReactNode }) => ( 23 |
    {props.children}
    24 | ) 25 | const Child = (_: { children: React.ReactNode }) => undefined 26 | const isolated = isolateComponent( 27 | 28 | a b c 29 | 30 | ) 31 | assert.strictEqual(isolated.content(), 'a b c') 32 | assert.strictEqual(isolated.toString(), '
    a b c
    ') 33 | }) 34 | 35 | it('handles fragments', () => { 36 | const FragmentExample = () => ( 37 | <> 38 |
    A
    39 |
    B
    40 | 41 | ) 42 | 43 | const isolated = isolateComponent() 44 | assert.strictEqual(isolated.content(), '
    A
    B
    ') 45 | }) 46 | 47 | it('handles child function', () => { 48 | const FunctionExample = () =>
    {(() => '42') as any}
    49 | 50 | const isolated = isolateComponent() 51 | assert.strictEqual(isolated.content(), '[Function]') 52 | assert.strictEqual(isolated.toString(), '
    [Function]
    ') 53 | }) 54 | 55 | it('handles deep fragments', () => { 56 | const FragmentExample: React.FC<{}> = () => ( 57 |
    58 | <> 59 |
    A
    60 |
    B
    61 | 62 |
    63 | ) 64 | 65 | const isolated = isolateComponent() 66 | assert.strictEqual(isolated.content(), '
    A
    B
    ') 67 | assert.strictEqual( 68 | isolated.toString(), 69 | '
    A
    B
    ' 70 | ) 71 | }) 72 | 73 | it('handles array at top level', () => { 74 | // typescript doesn't support this but it is valid react 75 | const ArrayExample: any = () => [
    A
    ,
    B
    ] 76 | 77 | const isolated = isolateComponent() 78 | assert.strictEqual(isolated.content(), '
    A
    B
    ') 79 | }) 80 | 81 | it('handles updating inlined components', () => { 82 | const Div = ({ children }: { children: React.ReactNode }) => ( 83 |
    {children}
    84 | ) 85 | 86 | const Example = (props: { items: string[] }) => ( 87 |
    88 | {props.items.map((x, i) => ( 89 |
    {x}
    90 | ))} 91 |
    92 | ) 93 | 94 | const isolated = isolateComponent() 95 | 96 | isolated.inline('*') 97 | 98 | assert.strictEqual(isolated.content(), '
    A
    ') 99 | 100 | isolated.setProps({ items: ['A', 'B'] }) 101 | assert.strictEqual(isolated.content(), '
    A
    B
    ') 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /isolate-react/test/isolateComponent/context_consumer.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha' 2 | import assert from 'node:assert' 3 | import React, { useContext, useEffect, useState } from 'react' 4 | import { 5 | isolateComponent, 6 | isolateComponentTree, 7 | } from '../../src/isolateComponent' 8 | 9 | const HelloContext = React.createContext<{ 10 | name: string 11 | setName: (name: string) => void 12 | }>({ name: 'Ford', setName: () => {} }) 13 | 14 | describe('context provider and consumer', () => { 15 | const HelloDisplay = () => { 16 | return ( 17 | 18 | {({ name }) => { 19 | return
    Hello {name}
    20 | }} 21 |
    22 | ) 23 | } 24 | const HelloContextExample = (props: { name: string }) => { 25 | const [name, setName] = useState(props.name) 26 | return ( 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | it('can consume a value from provider', () => { 34 | const isolated = isolateComponent() 35 | 36 | isolated.inline('*') 37 | 38 | assert.strictEqual(isolated.content(), '
    Hello Arthur
    ') 39 | }) 40 | 41 | it('can consume a value from provider component', () => { 42 | const isolated = isolateComponent( 43 | {} }}> 44 | 45 | 46 | ) 47 | 48 | isolated.inline('*') 49 | 50 | assert.strictEqual(isolated.content(), '
    Hello Zaphod
    ') 51 | }) 52 | 53 | it('can consume an explicitly set value', () => { 54 | const isolated = isolateComponent( 55 | 56 | {({ name }) =>
    Hello {name}
    } 57 |
    58 | ) 59 | 60 | isolated.inline('*') 61 | isolated.setContext(HelloContext, { name: 'Trillian', setName: () => {} }) 62 | 63 | assert.strictEqual(isolated.toString(), '
    Hello Trillian
    ') 64 | }) 65 | 66 | it('can setContext', () => { 67 | const isolated = isolateComponent() 68 | 69 | isolated.inline('*') 70 | isolated.setContext(HelloContext, { name: 'Trillian', setName: () => {} }) 71 | 72 | assert.strictEqual(isolated.toString(), '
    Hello Trillian
    ') 73 | }) 74 | 75 | it('can update value from within consumer in an effect', () => { 76 | const HelloStateProvider = (props: { 77 | name: string 78 | children: React.ReactNode 79 | }) => { 80 | const [name, setName] = useState(props.name) 81 | return ( 82 | 83 | {props.children} 84 | 85 | ) 86 | } 87 | const UpdateName = (props: { name: string }) => { 88 | return ( 89 | 90 | {({ setName, name }) => { 91 | useEffect(() => { 92 | if (name !== props.name) { 93 | setName(props.name) 94 | } 95 | }, [setName, name]) 96 | return null 97 | }} 98 | 99 | ) 100 | } 101 | 102 | const UseContext = () => { 103 | const { name, setName } = useContext(HelloContext) 104 | return 105 | } 106 | 107 | const isolated = isolateComponentTree( 108 | 109 | 110 | 111 | 112 | 113 | ) 114 | 115 | isolated.setContext(HelloContext, { name: 'Booty', setName: () => {} }) 116 | 117 | assert.strictEqual(isolated.findOne('div').content(), 'Hello Arthur') 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /isolate-react/test/isolateComponent/disableReactWarnings.ts: -------------------------------------------------------------------------------- 1 | export const disableReactWarnings = () => { 2 | let originalError: any 3 | before(() => { 4 | originalError = console.error.bind(console) 5 | console.error = () => {} 6 | }) 7 | 8 | after(() => { 9 | console.error = originalError 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /isolate-react/test/isolateComponent/forward_ref.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha' 2 | import React from 'react' 3 | import { isolateComponent } from '../../src/isolateComponent' 4 | 5 | // support components that use forwardRef, such as styled-components 6 | describe('forwardRef', () => { 7 | const InnerButton = () =>