Repository: forgojs/forgo Branch: main Commit: 9c199391781e Files: 85 Total size: 233.6 KB Directory structure: gitextract_kl2ed4jz/ ├── .eslintrc.cjs ├── .gitignore ├── .npmignore ├── .vscode/ │ ├── launch.json │ └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src/ │ ├── index.ts │ ├── jsxTypes.ts │ └── test/ │ ├── README.md │ ├── afterRender/ │ │ ├── index.ts │ │ └── script.tsx │ ├── assertIsComponent/ │ │ ├── index.ts │ │ └── script.tsx │ ├── boundary/ │ │ ├── index.ts │ │ └── script.tsx │ ├── childWithFragmentUnmounts/ │ │ ├── index.ts │ │ └── script.tsx │ ├── clearsOldProps/ │ │ ├── index.ts │ │ └── script.tsx │ ├── componentApi.tsx │ ├── componentFragment/ │ │ ├── index.ts │ │ └── script.tsx │ ├── componentKeepsStateWhenReordered/ │ │ ├── index.ts │ │ └── script.tsx │ ├── componentMount.tsx │ ├── componentRunner.tsx │ ├── componentUnmount.tsx │ ├── css/ │ │ ├── index.ts │ │ └── script.tsx │ ├── dangerouslySetInnerHTML/ │ │ ├── index.ts │ │ └── script.tsx │ ├── elementKeepsStateWhenReordered/ │ │ ├── index.ts │ │ └── script.tsx │ ├── elementRef/ │ │ ├── index.ts │ │ └── script.tsx │ ├── fragmentMountEvent.tsx │ ├── fragmentOverwriteDoesNotUnmount/ │ │ ├── index.ts │ │ └── script.tsx │ ├── fragmentUnmountRunsOnce/ │ │ ├── index.ts │ │ └── script.tsx │ ├── htmlFile.ts │ ├── hydrate/ │ │ ├── index.ts │ │ └── script.tsx │ ├── inheritedCustomElement/ │ │ ├── index.ts │ │ └── script.tsx │ ├── keyedFragmentsPreserveChildStates/ │ │ ├── index.ts │ │ └── script.tsx │ ├── mount/ │ │ ├── index.ts │ │ └── script.tsx │ ├── mountRunsOnceWhenChildRendersFragment/ │ │ ├── index.ts │ │ └── script.tsx │ ├── mountRunsOnceWhenRenderingFragment/ │ │ ├── index.ts │ │ └── script.tsx │ ├── nodeState/ │ │ ├── index.ts │ │ └── script.tsx │ ├── package1.json │ ├── passProps/ │ │ ├── index.ts │ │ └── script.tsx │ ├── propsChanges/ │ │ ├── index.ts │ │ └── script.tsx │ ├── renderPrimitives/ │ │ ├── index.ts │ │ └── script.tsx │ ├── rendersArraysInChildren/ │ │ ├── index.ts │ │ └── script.tsx │ ├── replaceByKey/ │ │ ├── index.ts │ │ └── script.tsx │ ├── replacingFragmentWithNodeWontUnmount/ │ │ ├── index.ts │ │ └── script.tsx │ ├── rerender.tsx │ ├── rerenderChild/ │ │ ├── index.ts │ │ └── script.tsx │ ├── rerenderMayChangeRootNode.tsx │ ├── rerenderMayChangeRootNodeOnParents.tsx │ ├── rerenderMayUnmountParents.tsx │ ├── rootElementChangeDoesNotUnmount.tsx │ ├── shouldUpdate.tsx │ ├── ssr-simple.tsx │ ├── test.ts │ ├── unmountsNonTopLevelParentWhenNodeIsNull/ │ │ └── script.tsx │ ├── unmountsParentWhenNodeIsNull/ │ │ └── script.tsx │ └── unmountsParentWhenNodeIsNull.tsx └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.cjs ================================================ module.exports = { extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, rules: { "@typescript-eslint/strict-boolean-expressions": ["error"], }, parserOptions: { sourceType: "module", project: "tsconfig.json", tsconfigRootDir: "./", }, }; ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output .next # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and *not* Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port .vscode/settings.json ================================================ FILE: .npmignore ================================================ .vscode dist/test/* src/test/* ================================================ FILE: .vscode/launch.json ================================================ { "configurations": [ { "args": [ "--timeout", "999999", "--colors", "${workspaceFolder}/dist/test/test" ], "internalConsoleOptions": "openOnSessionStart", "name": "Mocha Tests (all)", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "request": "launch", "skipFiles": ["/**"], "type": "node", "preLaunchTask": "build-dev" }, { "type": "node", "request": "launch", "name": "Mocha (filtered)", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "args": [ "--timeout", "999999", "--colors", "${workspaceFolder}/dist/test/test", "-f", "'${input:filter}'" ], "internalConsoleOptions": "openOnSessionStart", "skipFiles": ["/**"], "preLaunchTask": "build-dev" } ], "inputs": [ { "id": "filter", "type": "promptString", "description": "Enter a Mocha tests filter" } ] } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "type": "npm", "label": "build-dev", "script": "build-dev" } ] } ================================================ FILE: CHANGELOG.md ================================================ # 3.2.2 - Fix #76: Add support for TypeScript 4.8 # 3.2.1 - Feature #73: Add a function to unmount a component tree from outside the Forgo - Fix #50: Components that returned a fragment saw their `mount` lifecycle method called after the first child element had been created instead of after the render had completed. - Fix #70: Calling `component.update()` during a mount lifecycle handler resulted in the component recursively mounting ad infinitum. - Fix #75: ESLint plugin `eslint-plugin-import` could not resolve imports of Forgo - Add TSX support for custom element tag names. Non-string attributes are not yet supported # 3.2.0 - #59: Forgo's legacy component syntax (component syntax used through v3.1.1) has been deprecated, and will be removed in v4.0. For more details, please see the deprecation notice on https://forgojs.org. - Fix #62: ensure that a child component's `mount()` lifecycle method is only called after its parent has completely finished rendering - Feature: Allow components to return `null` or `undefined` from their `render()` method (#39) # 3.1.1 - Fix: components that changed their root HTML tag were erroneously unmounted # 3.0.2 - Fix: component teardown left old DOM elements in memory (#47) # 3.0.0 - Feature: Allow the user to manually add DOM elements to a rendered component without modifying or removing them. This allows e.g., using charting libraries with Forgo. (#42) - Fix: allow components & elements to receive falsey `key`s (`0`, `false`, `null`) (#45) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Jeswin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # forgo Forgo is a 4KB library that makes it super easy to create modern web apps using JSX (like React). Unlike React, there are very few framework specific patterns and lingo to learn. Everything you already know about DOM APIs and JavaScript will easily carry over. - Use HTML DOM APIs for accessing elements - There are no synthetic events - Use closures and ordinary variables for maintaining component state - There's no vDOM or DOM diffing - Renders are manually triggered - Declarative DOM updates ## We'll be tiny. Always. All of Forgo's code is in one single TypeScript file. It is a goal of the project to remain within that single file. ## Installation ``` npm install forgo ``` ## Starting a Forgo project The easiest way to get started is with the 'create-forgo-app' utility. This relies on git, so you should have git installed on your machine. ```sh npx create-forgo-app my-project ``` It supports TypeScript too: ```sh npx create-forgo-app my-project --template typescript ``` And then to run it: ```sh # Switch to the project directory cd my-project # Run! npm start # To make a production build npm run build ``` ## A Forgo Component Forgo components are functions that return a Component instance, which has a `render()` method that returns JSX. Components hold their state using ordinary variables held in the closure scope of the function (called a Component Constructor). Forgo likes to keep things simple and explicit. It avoids automatic behavior, prefers basic functions and variables instead of implicit constructs, and tries not to come between you and the DOM. Here's the smallest Forgo component you can make: ```jsx import * as forgo from "forgo"; const HelloWorld = () => { return new forgo.Component({ render() { return

Hello, world!

; } }); }; ``` When a component is created (either by being mounted onto the DOM when the page loads, or because it was rendered by a component higher up in your app), Forgo calls the Component Constructor to generate the component instance. This is where you can put closure variables to hold the component's state. Then Forgo calls the component's `render()` method to generate the HTML that Forgo will show on the page. After the component's first render, the Constructor won't be called again, but the `render()` method will be called each time the component (or one of its ancestors) rerenders. Forgo will pass any props (i.e., HTML attributes from your JSX) to both the Constructor and the `render()` method. Here's a bigger example - the component below increments a counter when a button is pressed, and counts how many seconds the component has been alive. ```jsx import * as forgo from "forgo"; const ClickCounter = (initialProps) => { let seconds = 0; // Just a regular variable, no hooks! let clickCounter = 0; const component = new forgo.Component({ // Every component has a render() method, which declares what HTML Forgo // needs to generate. render(props) { const { firstName } = props; // You can declare any DOM event handlers you need inside the render() // method. const onclick = (_event: Event) => { // Forgo doesn't know or care how you manage your state. This frees you // to use any library or code pattern that suits your situation, not // only tools designed to integrate with the framework. clickCounter += 1; // When you're ready to rerender, just call component.update(). Manual // updates mean the framework only does what you tell it to, putting you // in control of efficiency and business logic. // // An optional package, forgo-state, can automate this for simple scenarios. component.update(); }; // Forgo uses JSX, like React or Solid, to generate HTML declaratively. // JSX is a special syntax for JavaScript, which means you can treat it // like ordinary code (assign it to variables, type check it, etc.). return (

Hello, {firstName}!

); } }); // You can add callbacks to react to lifecycle events, // like mounting and unmounting component.mount(() => { const timeout = setTimeout(() => { seconds++; component.update(); }, 1000); component.unmount(() => clearTimeout(timeout)); }); return component; }; ``` Here's how the API looks when using TypeScript (which is optional): ```tsx import * as forgo from "forgo"; // The constructor generic type accepts the shape of your component's props const HelloWorld = () => { return new forgo.Component({ render({ name }) { return

Hello, {name}!

; } }); }; ``` If you assign the component to a variable (such as when adding lifecycle event handlers), you'll need to annotate the generic types on both the constructor and the component. Generic props can also be used: ```tsx import * as forgo from "forgo"; // Props have to be assigned to the initial props for TSX to recognize the generic type ListProps = { data: T[]; }; const List = (initial: ListProps) => new forgo.Component>({ render(props) { return (
    {props.data.map((item) => (
  • {item}
  • ))}
); }, }); const App = () => new forgo.Component({ render(props) { return ; }, }); ``` _If you're handy with TypeScript, [we'd love a PR to infer the types!](https://github.com/forgojs/forgo/issues/68)_ ```tsx import * as forgo from "forgo"; interface HelloWorldProps { name: string; } const HelloWorld = () => { const component = new forgo.Component({ render({ name }) { return

Hello, {name}!

; } }); component.mount(() => console.log("Mounted!")); return component; }; ``` ## Launching your components when the page loads Use the mount() function once your document has loaded. ```js import { mount } from "forgo"; // Wait for the page DOM to be ready for changes function ready(fn) { if (document.readyState !== "loading") { fn(); } else { document.addEventListener("DOMContentLoaded", fn); } } ready(() => { // Attach your app's root component to a specific DOM element mount(, document.getElementById("root")); }); ``` Instead of retrieving the DOM element yoursely, you could pass a CSS selector and Forgo will find the element for you: ```js ready(() => { mount(, "#root"); }); ``` ## Child Components and Passing Props Props and children work just like in React and similar frameworks: ```jsx // Component Constructors will receive the props passed the *first* time the // component renders. But beware! This value won't be updated on later renders. // Props passod to the Constructor are useful for one-time setup, but to read // the latest props you'll need to use the value passed to render(). const Parent = (_initialProps) => { return new forgo.Component({ // The props passed here will always be up-to-date. // // All lifecycle methods (render, mount, etc.) receive a reference to the // component. This makes it easy to create reusable logic that works for // many different components. render(_props, _component) { return (
); } }); }; const Greeter = (_initialProps) => { return new forgo.Component({ render(props, _component) { return
Hello {props.firstName}
; } }); }; ``` You can pass any kind of value as a prop - not just strings! You just have to use curly braces instead of quotes: ```jsx const MyComponent = () => { return new forgo.Component({ render(_props) { return ; } }); }; ``` You can have one component wrap JSX provided by another. To do this, just render `props.children`. ```jsx const Parent = () => { return new forgo.Component({ render(_props) { return

Hello, world!

) } }); } const Child = () => { return new forgo.Component({ render(props) { return (

Here's what the parent told us to render:

{props.children}
) } }); } ``` ## Reading Form Input Elements Forgo encourages you to use the vanilla DOM API when you need to read form field values, by directly accessing the DOM elements in the form. To access the actual DOM elements corresponding to your markup (and the values contained within them), you need to use the `ref` attribute in the JSX markup of the element you want to reference. An element referenced by the `ref` attribute will have its 'value' property set to the actual DOM element when it gets created. Here's an example: ```jsx const MyComponent = (_initialProps) => { // This starts as an empty object. After the element is created, this object // will have a `value` field holding the element. const myInputRef = {}; return new forgo.Component({ render(_props, _component) { const onClick = () => { const inputElement = myInputRef.value; alert(inputElement.value); // Read the text input. }; return (
); } }); }; ``` If you want, you can bypass Forgo entirely when reading form field values. If you set the `id` field on the form field, then you could use the vanilla DOM API to access that element directly: ```jsx const onClick = () => { const inputElement = document.getElementById("my-input"); alert(inputElement.value); }; ``` Lastly, DOM events like key presses and clicks pass the affected element to the event handler as `event.target`: ```jsx const Component = (_initialProps) => { return new forgo.Component({ render(_props, _component) { const onInput = (event) => { alert(event.target.value); }; return (
); } }); }; ``` ## Rendering Lists and using Keys Forgo will render any arrays it sees in the JSX. To create a list of elements, just use the array's `myArray.map()` method to generate JSX for each item in the array. Each item in the array may be given a `key` attribute. Keys help Forgo identify which items in a list have changed, are added, or are removed. While Forgo works well without keys, it is a good idea to add them since it lets Forgo be more efficient by only mounting or unmounting components that actually need it. You can use any data type for a key strings, numbers or even objects. The key values only need to be unique. Forgo compares keys using `===` (reference equality), so be careful when using mutable objects as keys. When looping over an array, don't use the array index as a key - keys should be something tied to the specific value being rendered (like a permanent ID field). The same array index might be associated with different values if you reorder the array, and so using the array index as a key will cause unexpected behavior. ```jsx const Parent = () => { return new forgo.Component({ render(_props, _component) { const people = [ { firstName: "jeswin", id: 123 }, { firstName: "kai", id: 456 }, ]; return (
{people.map((item) => ( ))}
); } }); }; const Child = (initialProps) => { return new forgo.Component({ render(props) { return
Hello {props.firstName}
; }, }); }; ``` ## Fetching data asynchronously Your component might need to load data asynchronously (such as making a network request). Here's how to do that: ```jsx export const InboxComponent = (_initialProps) => { // This will be empty at first, and will get filled in sometime after the // component first mounts. let messages = undefined; const component = new forgo.Component({ render(_props, _component) { // Messages are empty. Let's fetch them. if (!messages) { return

Loading data...

; } // After messages are fetched, the component will rerender and now we can // show the data. return (
Your Inbox
    {messages.map((message) => (
  • {message}
  • ))}
); } }); component.mount(async () => { messages = await fetchMessagesFromServer(); component.update(); }); return component; }; ``` ## The Mount Event The mount event is fired just once per component, when the component has just been created. This is useful for set-up logic like starting a timer, fetching data, or opening a WebSocket. You can register multiple mount callbacks, which is useful if you want to have reusable logic that you apply to a number of components. ```jsx const Greeter = (_initialProps) => { const component = new forgo.Component({ render(_props, _component) { return
Hello {props.firstName}
; } }); component.mount((_props, _component) => { console.log("The component has been mounted."); }); return component; }; ``` ## The Unmount Event A component is unmounted when your app no longer renders it (such as when a parent component chooses to display something different, or when an item is removed from a list you're rendering). When a component is unmounted, you might want to do tear-down, like canceling a timer or closing a WebSocket. To do this, you can register unmount callbacks on your component, which will be called when the component is unmounted. The callbacks are passed the current props and the component instance, just like the `render()` method. ```jsx const Greeter = (_initialProps) => { const component = new forgo.Component({ render(props, _component) { return
Hello {props.firstName}
; } }); component.unmount((props, _component) => { console.log("The component has been unloaded."); }); return component; }; ``` ## Skipping renders Sometimes you have a reason why a component shouldn't be rendered right now. For example, if you're using immutable data structures, you may want to only rerender if the data structure has changed. Forgo components accept `shouldUpdate` callbacks, which return true/false to signal whether the component should / should not be rerendered. If any `shouldUpdate` callbacks return true, the component will be rerendered. If they all return false (or if none are registered), the component's `render()` method won't be called, skipping all DOM operations for the component and its decendants. The callbacks receive the new props for the proposed render, and the old props used in the last render. Using `shouldUpdate` is completely optional, and typically isn't necessary. ```jsx const Greeter = (_initialProps) => { const component = new forgo.Component({ render(props, component) { return
Hello {props.firstName}
; } }); component.shouldUpdate((newProps, oldProps) => { return newProps.firstName !== oldProps.firstName; }); return component; } ``` ## Error handling Forgo lets components define an `error()` method, which is run any time the component (or any of its decendants) throws an exception while running the component's `render()` method. The error method can return JSX that is rendered in place of the render output, to display an error message to the user. If no ancestors have an `error()` method registered, the render will abort and Forgo will print an error to the console. ```jsx // Here's a component which throws an error. const BadComponent = () => { return new forgo.Component({ render() { throw new Error("Some error occurred :("); } }); } // The first ancestor with an error() method defined will catch the error const Parent = (initialProps) => { return new forgo.Component({ render() { return (
); }, error(props, error, _component) { return (

Error in {props.name}: {error.message}

); } }); } ``` ## The AfterRender Event If you're an application developer you'll rarely need to use this - it's provided for building libraries that wrap Forgo. The `afterRender` event runs after `render()` has been called and the rendered elements have been created in the DOM. The callback is passed the previous DOM element the component was attached to, if it changed in the latest render. ```jsx const Greeter = (_initialProps) => { const component = new forgo.Component({ render(props, component) { return
Hello {props.firstName}
; } }); component.afterRender((_props, previousNode, _component) => { console.log( `This component is mounted on ${component.__internal.element.node.id}, and was previously mounted on ${previousNode.id}` ); }); return component; }; ``` ## Passing new props when rerendering The most straight forward way to do rerender is by invoking it with `component.update()`, as follows: ```jsx const TodoList = (initialProps) => { let todos = []; return new forgo.Component({ render(props, component) { const addTodos = (text) => { todos.push(text); component.update(); }; return ( ); } }); } ``` `component.update()` may optionally receive new props to use in the render. Omitting the props parameter will rerender leave the props unchanged. ```js const newProps = { name: "Kai" }; component.update(newProps); ``` ## Rendering without mounting Forgo also exports a render method that returns the rendered DOM node that could then be manually mounted. ```jsx import { render } from "forgo"; const { node } = render(); window.addEventListener("load", () => { document.getElementById("root").firstElementChild.replaceWith(node); }); ``` ## Routing Forgo offers an optional package (`forgo-router`) for handling client-side navigation. Forgo Router is just around 1KB gzipped. Read more at https://github.com/forgojs/forgo-router Here's an example: ```jsx import { Router, Link, matchExactUrl, matchUrl } from "forgo-router"; const App = () => { return new forgo.Component({ render() { return ( Go to Home Page {matchExactUrl("/", () => ) || matchUrl("/customers", () => ) || matchUrl("/about", () => )} ); } }); } ``` ## Application State Management Forgo offers an optional package (`forgo-state`) with an easy-to-use application state management solution for Forgo. This solves a similar problem to Redux or MobX. It's than 1KB gzipped. Read more at https://github.com/forgojs/forgo-state Here's an example: ```jsx import { bindToStates, defineState } from "forgo-state"; // Define one (or more) application state containers. const mailboxState = defineState({ username: "Bender B. Rodriguez", messages: [], drafts: [], spam: [], unread: 0 }); // A Forgo component that should react to state changes const MailboxView = () => { const component = new forgo.Component({ render() { if (mailboxState.messages.length > 0) { return (
{mailboxState.messages.map((m) =>

{m}

)}
); } return (

There are no messages for {mailboxState.username}.

); } }); component.mount(() => updateInbox()); // MailboxView must change whenever mailboxState changes. // // Under the hood, this registers component.mount() and component.unmount() // even handlers bindToStates([mailboxState], component); return component; } async function updateInbox() { const data = await fetchInboxData(); // The next line causes a rerender of the MailboxView component mailboxState.messages = data; } ``` ## Lazy Loading If you want to lazy load a component, you can use the community-provided `forgo-lazy` package. This is useful for code splitting, where you want the initial page load to be quick (loading the smallest JS possible), and then load in more components only when the user needs them. Read more at https://github.com/jacob-ebey/forgo-lazy It's works like this: ```jsx import lazy, { Suspense } from "forgo-lazy"; const LazyComponent = lazy(() => import("./lazy-component")); const App = () => { return new forgo.Component({ render() { return ( "Loading..."}> ); } }); } ``` ## Integrating Forgo into an existing app Forgo can be integrated into an existing web app written with other frameworks (React, Vue, etc.), or with lower-level libraries like jQuery. To help with that, the `forgo-powertoys` package (less than 1KB in size) exposes a `rerenderElement()` function which can receive a CSS selector and rerender the Forgo component associated with that element. This works from outside the Forgo app, so you can drive Forgo components using your framework/library of choice. Read more at https://github.com/forgojs/forgo-powertoys Here's an example: ```jsx import { rerenderElement } from "forgo-powertoys"; // A forgo component. const LiveScores = () => { return new forgo.Component({ render(props) { return

Top score is {props.topscore}

; } }); } // Mount it on a DOM node usual window.addEventListener("load", () => { mount(, document.getElementById("root")); }); // Now you can rerender the component from anywhere, anytime! Pass in the ID of // the root element the component returns, as well as new props. rerenderElement("#live-scores", { topscore: 244 }); ``` ## Server-side Rendering (SSR) From Node.js you can render components to an HTML string with the `forgo-ssr` package. This allows you to prerender components on the server, from server-side frameworks like Koa, Express etc. Read more at https://github.com/forgojs/forgo-ssr Here's an example: ```jsx import render from "forgo-ssr"; // A forgo component. const MyComponent = () => { return new forgo.Component({ render() { return
Hello world
; } }); } // Get the html (string) and serve it via koa, express etc. const html = render(); ``` ## Manually adding elements to the DOM Forgo allows you to use the built-in browser DOM API to insert elements into the DOM tree rendered by a Forgo component. Forgo will ignore these elements. This is useful for working with charting libraries, such as D3. If you add unmanaged nodes as siblings to nodes which Forgo manages, Forgo pushes the unmanaged nodes towards the bottom of the sibling list when managed nodes are added and removed. If you don't add/remove managed nodes, the unmanaged nodes will stay in their original positions. ### ApexCharts example [Code Sandbox](https://codesandbox.io/s/forgo-apexcharts-demo-ulkqfe?file=/src/index.tsx) for this example ```jsx const App = () => { const chartElement = {}; const component = new forgo.Component({ render(_props, component) { const now = new Date(); return (

This component continually rerenders. Forgo manages the timestamp, but delegates control of the chart to ApexCharts.

The current time is:{" "}

); } }); component.mount(() => { const chartOptions = { chart: { type: "line", }, series: [ { name: "sales", data: [30, 40, 35, 50, 49, 60, 70, 91, 125], }, ], xaxis: { categories: [1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999], }, }; const chart = new ApexCharts(chartElement.value, chartOptions); chart.render(); const interval = setInterval(() => component.update(), 1_000); component.unmount(() => clearInterval(interval)); }); return component; }; ``` ## Try it out on CodeSandbox You can try the [Todo List app with Forgo](https://codesandbox.io/s/forgo-todos-javascript-1oi9b) on CodeSandbox. Or if you prefer TypeScript, try [Forgo TodoList in TypeScript](https://codesandbox.io/s/forgo-todos-typescript-9v0iy). There is also an example for using [Forgo with forgo-router](https://codesandbox.io/s/forgo-router-typescript-px4sg). ## Building Most users should use create-forgo-app to create the project skeleton - in which case all of this is already set up for you. This is the easiest way to get started. If you want to stand up a project manually, we'll cover webpack-specific configuration here. Other bundlers would need similar configuration. ### esbuild-loader with JavaScript/JSX Add these lines to webpack.config.js: ```js module.exports = { // remaining config omitted for brevity. module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, loader: "esbuild-loader", options: { loader: "jsx", target: "es2015", jsxFactory: "forgo.createElement", jsxFragment: "forgo.Fragment", }, }, ], }, }; ``` ### esbuild-loader with TypeScript/TSX Add these lines to webpack.config.js: ```js module.exports = { // remaining config omitted for brevity. module: { rules: [ { test: /\.tsx?$/, exclude: /node_modules/, loader: "esbuild-loader", options: { loader: "tsx", target: "es2015", jsxFactory: "forgo.createElement", jsxFragment: "forgo.Fragment", }, }, ], }, }; ``` While using TypeScript, also add the following lines to your tsconfig.json. This lets you do `tsc --noEmit` for type checking, which esbuild-loader doesn't do. Add these lines to tsconfig.json: ```json { "compilerOptions": { "jsx": "react", "jsxFactory": "forgo.createElement", "jsxFragmentFactory": "forgo.Fragment" } } ``` ### babel-loader with JSX This is slower than esbuild-loader, so use only as needed. Add these lines to webpack.config.js: ```js module.exports = { // remaining config omitted for brevity. module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, use: ["babel-loader"], }, ], }, }; ``` Add these lines to babel.config.json: ```json { "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": [ ["@babel/plugin-transform-react-jsx", { "pragma": "forgo.createElement" }] ] } ``` ### TSX with ts-loader Add these lines to webpack.config.js: ```js module.exports = { // remaining config omitted for brevity. module: { rules: [ { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/, }, ], }, }; ``` Add these lines to tsconfig.json: ```json { "compilerOptions": { "jsx": "react", "jsxFactory": "forgo.createElement", "jsxFragmentFactory": "forgo.Fragment" } } ``` ## Core Team - [github/jeswin](https://github.com/jeswin) - [github/spiffytech](https://github.com/spiffytech) ## Getting Help If you find issues, please file a bug on [Github](https://github.com/forgojs/forgo/issues). You can also reach out to us via Twitter (@forgojs). ## Deprecation of legacy component syntax is 3.2.0 In version 3.2.0, Forgo introduced a new syntax for components. This change makes Forgo easier to extend with reusable libraries, and makes it straightforward to colocate logic that spans mounts & unmounts. The legacy component syntax will be removed in v4.0. Until then, Forgo will print a warning to the console whenever it sees a legacy component. You can suppress these warnings by setting `window.FORGO_NO_LEGACY_WARN = true;`. ### Migrating Forgo components are now instances of the `Component` class, rather than freestanding object values. The `new Component` constructor accepts an object holding a `render()` an optional `error()` method. All other methods have been converted to lifecycle methods on the component instance. You may register multiple handlers for each lifecycle event, and you may register new handlers from inside a handler (e.g., a mount handler that registers its own unmount logic). `args` has been replaced by a reference to the component instance, in all lifecycle event handlers. This simplifies writing reusable component logic. The `error()` method now receives the error object as a function parameter, rather than as a property on `args`. The `afterRender` lifecycle event now receives the `previousNode` as a function parameter, instead of a property on `args`. Before: ```jsx const MyComponent = () => { return { render() {}, error() {}, mount() {}, unmount() {}, shouldUpdate() {}, afterRender() {}, }; } ``` After: ```jsx const MyComponent = () => { const component = new Component({ render() {}, error() {} }); component.mount(() => {}); component.unmount(() => {}); component.shouldUpdate(() => {}); component.afterRender(() => {}); return component; } ``` ## Breaking changes in 2.0 Forgo 2.0 drops support for the new JSX transform introduced via "jsx-runtime". This never worked with esbuild loader, and more importantly doesn't play well with ES modules. If you were using this previously, switch to the configurations discussed above. ================================================ FILE: package.json ================================================ { "name": "forgo", "version": "4.1.7", "main": "./dist/forgo.min.js", "type": "module", "author": "Jeswin Kumar", "repository": { "type": "git", "url": "https://github.com/forgojs/forgo" }, "exports": "./dist/forgo.min.js", "types": "./dist/index.d.ts", "devDependencies": { "@types/jsdom": "^21.1.0", "@types/mocha": "^10.0.1", "@types/should": "^13.0.0", "@types/source-map-support": "^0.5.6", "@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/parser": "^5.52.0", "esbuild": "^0.17.8", "eslint": "^8.34.0", "jsdom": "^21.1.0", "mocha": "^10.2.0", "rimraf": "^4.1.2", "should": "^13.2.3", "source-map-support": "^0.5.21", "typescript": "^4.9.5" }, "scripts": { "clean": "rimraf ./dist", "build": "npm run clean && npx tsc --emitDeclarationOnly && npx esbuild ./src/index.ts --minify --bundle --format=esm --sourcemap --target=es2015 --outfile=dist/forgo.min.js", "build-dev": "npx tsc", "test": "npx tsc && npx mocha dist/test/test.js" }, "license": "MIT" } ================================================ FILE: src/index.ts ================================================ /* A type that wraps a reference. */ export type ForgoRef = { value?: T; }; /* We have two types of elements: 1. DOM Elements 2. Component Elements */ export type ForgoElementBaseProps = { children?: ForgoNode | ForgoNode[]; ref?: ForgoRef; }; // DOM elements have the additional fields export type ForgoDOMElementProps = { xmlns?: string; dangerouslySetInnerHTML?: { __html: string }; } & ForgoElementBaseProps; // Since we'll set any attribute the user passes us, we need to be sure not to // set Forgo-only attributes that don't make sense to appear in the DOM const suppressedAttributes = ["ref", "dangerouslySetInnerHTML"]; export type ForgoSimpleComponentCtor = ( props: TProps & ForgoElementBaseProps ) => ForgoLegacyComponent; export type ForgoNewComponentCtor = ( props: TProps & ForgoElementBaseProps ) => Component; export type ForgoElementArg = { node?: ChildNode; componentIndex: number; }; /* A ForgoNode is the output of the render() function. It can represent: - a primitive type which becomes a DOM Text Node - a DOM Element - or a Component. If the ForgoNode is a string, number etc, it's a primitive type. eg: "hello" If ForgoNode has a type property which is a string, it represents a native DOM element. eg: The type will be "div" for
Hello
If the ForgoElement represents a Component, then the type points to a ForgoComponentCtor. eg: The type will be MyComponent for */ export type ForgoElementBase = { key?: any; props: TProps; __is_forgo_element__: true; }; export type ForgoDOMElement = ForgoElementBase & { type: string; }; export type ForgoComponentElement = ForgoElementBase & { type: ForgoNewComponentCtor; }; export type ForgoFragment = { type: typeof Fragment; props: { children?: ForgoNode | ForgoNode[] }; __is_forgo_element__: true; }; export type ForgoElement = | ForgoDOMElement | ForgoComponentElement; export type ForgoNonEmptyPrimitiveNode = | string | number | boolean | object | bigint; export type ForgoPrimitiveNode = ForgoNonEmptyPrimitiveNode | null | undefined; /** * Anything renderable by Forgo, whether from an external source (e.g., * component.render() output), or internally (e.g., DOM nodes) */ export type ForgoNode = ForgoPrimitiveNode | ForgoElement | ForgoFragment; /* Forgo stores Component state on the element on which it is mounted. Say Custom1 renders Custom2 which renders Custom3 which renders
Hello
. In this case, the components Custom1, Custom2 and Custom3 are stored on the div. You can also see that it gets passed around as pendingStates in the render methods. That's because when Custom1 renders Custom2, there isn't a real DOM node available to attach the state to. So the states are passed around until the last component renders a real DOM node or nodes. In addition it holds a bunch of other things. Like for example, a key which uniquely identifies a child element when rendering a list. */ export type NodeAttachedComponentState = { key?: any; ctor: ForgoNewComponentCtor | ForgoSimpleComponentCtor; component: Component; props: TProps; nodes: ChildNode[]; isMounted: boolean; }; /* This is the state data structure which gets stored on a node. See explanation for NodeAttachedComponentState */ export type NodeAttachedState = { key?: string | number; props?: { [key: string]: any }; components: NodeAttachedComponentState[]; style?: { [key: string]: any }; deleted?: boolean; }; // CSS types lifted from preact. export type DOMCSSProperties = { [key in keyof Omit< CSSStyleDeclaration, | "item" | "setProperty" | "removeProperty" | "getPropertyValue" | "getPropertyPriority" >]?: string | number | null | undefined; }; export type AllCSSProperties = { [key: string]: string | number | null | undefined; }; export interface CSSProperties extends AllCSSProperties, DOMCSSProperties { cssText?: string | null; } /* The following adds support for injecting test environment objects. Such as JSDOM. */ export type ForgoEnvType = { window: Window; document: Document; __internal: { HTMLElement: typeof HTMLElement; Text: typeof Text; }; }; /** * Nodes will be created as detached DOM nodes, and will not be attached to the parent */ export type DetachedNodeInsertionOptions = { type: "detached"; }; /** * Instructs the renderer to search for an existing node to modify or replace, * before creating a new node. */ export type SearchableNodeInsertionOptions = { type: "new-component"; /** * The element that holds the previously-rendered version of this component */ parentElement: Element; /** * Where under the parent's children to find the start of this component */ currentNodeIndex: number; /** * How many elements after currentNodeIndex belong to the element we're * searching */ length: number; }; /** * Decides how the called function attaches nodes to the supplied parent */ export type NodeInsertionOptions = | DetachedNodeInsertionOptions | SearchableNodeInsertionOptions; /* Result of the render functions. */ export type RenderResult = { nodes: ChildNode[]; pendingMounts: (() => void)[]; }; export type DeletedNode = { node: ChildNode; }; declare global { interface ChildNode { __forgo?: NodeAttachedState; __forgo_deletedNodes?: DeletedNode[]; } } /* Fragment constructor. We simply use it as a marker in jsx-runtime. */ export const Fragment: unique symbol = Symbol.for("FORGO_FRAGMENT"); /* HTML Namespaces */ const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; const MATH_NAMESPACE = "http://www.w3.org/1998/Math/MathML"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const MISSING_COMPONENT_INDEX = -1; const MISSING_NODE_INDEX = -1; /* These come from the browser's Node interface, which defines an enum of node types. We'd like to just reference Node., but JSDOM makes us jump through hoops to do that because it hates adding new globals. Getting around that is more complex, and more bytes on the wire, than just hardcoding the constants we care about. */ const ELEMENT_NODE_TYPE = 1; const TEXT_NODE_TYPE = 3; const COMMENT_NODE_TYPE = 8; /** * These are methods that a component may implement. Every component is required * to have a render method. * 1. render() returns the actual DOM to render. * 2. error() is called when this component, or one of its children, throws an * error. */ export interface ForgoComponentMethods { render: ( props: TProps & ForgoElementBaseProps, component: Component ) => ForgoNode | ForgoNode[]; error?: ( props: TProps & ForgoElementBaseProps, error: unknown, component: Component ) => ForgoNode; } /** * This type gives us an exhaustive type check, guaranteeing that if we add a * new lifecycle event to the array, any types that can be derived from that * information will fail to typecheck until they handle the new event. */ type ComponentEventListenerBase = { // eslint-disable-next-line @typescript-eslint/ban-types [event in keyof typeof lifecycleEmitters]: Array; }; /** * It'd be nice if we could just use ComponentEventListenerBase, but the * shouldUpdate event gets processed differently, so we need a way to specify * that some event listeners have non-void return types */ // TODO: figure out if TS gets angry if the user passes an async function as an // event listener. Maybe we need to default to unknown instead of void for the // return type? interface ComponentEventListeners extends ComponentEventListenerBase { mount: Array< ( props: TProps & ForgoElementBaseProps, component: Component ) => void >; unmount: Array< ( props: TProps & ForgoElementBaseProps, component: Component ) => void >; afterRender: Array< ( props: TProps & ForgoElementBaseProps, previousNode: ChildNode | undefined, component: Component ) => void >; shouldUpdate: Array< ( newProps: TProps & ForgoElementBaseProps, oldProps: TProps & ForgoElementBaseProps, component: Component ) => boolean >; } interface ComponentInternal { unmounted: boolean; registeredMethods: ForgoComponentMethods; eventListeners: ComponentEventListeners; element: ForgoElementArg; } const lifecycleEmitters = { mount( component: Component, props: TProps ): void { component.__internal.eventListeners.mount.forEach((cb) => cb(props, component) ); }, unmount(component: Component, props: TProps) { component.__internal.eventListeners.unmount.forEach((cb) => cb(props, component) ); }, shouldUpdate( component: Component, newProps: TProps, oldProps: TProps ): boolean { // Always rerender unless we have a specific reason not to if (component.__internal.eventListeners.shouldUpdate.length === 0) return true; return component.__internal.eventListeners.shouldUpdate .map((cb) => cb(newProps, oldProps, component)) .some(Boolean); }, afterRender( component: Component, props: TProps, previousNode: ChildNode | undefined ) { component.__internal.eventListeners.afterRender.forEach((cb) => cb(props, previousNode, component) ); }, }; /** * This class represents your component. It holds lifecycle methods and event * listeners. You may pass it around your application and to 3rd-party libraries * to build reusable logic. */ export class Component { /** @internal */ public __internal: ComponentInternal; /** * @params methods The render method is mandatory. It receives your current * props and returns JSX that Forgo will render to the page. Other methods are * optional. See the forgojs.org for more details. */ constructor(registeredMethods: ForgoComponentMethods) { this.__internal = { registeredMethods, unmounted: false, eventListeners: { afterRender: [], mount: [], unmount: [], shouldUpdate: [], }, element: { componentIndex: MISSING_COMPONENT_INDEX, }, }; } public update(props?: TProps & ForgoElementBaseProps) { // TODO: When we do our next breaking change, there's no reason for this to // return anything, but we need to leave the behavior in while we have our // compatibility layer. return rerender(this.__internal.element, props); } public mount(listener: ComponentEventListeners["mount"][number]) { this.__internal.eventListeners["mount"].push(listener as any); } public unmount(listener: ComponentEventListeners["unmount"][number]) { this.__internal.eventListeners["unmount"].push(listener as any); } public shouldUpdate( listener: ComponentEventListeners["shouldUpdate"][number] ) { this.__internal.eventListeners["shouldUpdate"].push(listener as any); } public afterRender( listener: ComponentEventListeners["afterRender"][number] ) { this.__internal.eventListeners["afterRender"].push(listener as any); } } /** * jsxFactory function */ export function createElement< TProps extends ForgoDOMElementProps & { key?: any } >( type: | string | ForgoNewComponentCtor | ForgoSimpleComponentCtor, props: TProps, ...args: any[] ) { props = props ?? {}; props.children = args.length > 1 ? flatten(Array.from(args)) : args.length === 1 ? flatten(args[0]) : undefined; const key = props.key ?? undefined; return { type, props, key, __is_forgo_element__: true }; } export const h = createElement; /* HACK: Chrome fires onblur (if defined) immediately after a node.remove(). This is bad news for us, since a rerender() inside the onblur handler will run on an unattached node. So, disable onblur if node is set to be removed. */ function handlerDisabledOnNodeDelete(node: ChildNode, value: any) { return (e: any) => { if (node.__forgo === undefined || !node.__forgo.deleted) { return value(e); } }; } /** * Creates everything needed to run forgo, wrapped in a closure holding e.g., * JSDOM-specific environment overrides used in tests */ export function createForgoInstance(customEnv: any) { const env: ForgoEnvType = customEnv; env.__internal = env.__internal ?? { Text: (env.window as any).Text, HTMLElement: (env.window as any).HTMLElement, }; /** * This is the main render function. * @param forgoNode The node to render. Can be any value renderable by Forgo, * not just DOM nodes. * @param insertionOptions Which nodes need to be replaced by the new * node(s), or whether the new node should be created detached from the DOM * (without replacement). * @param statesAwaitingAttach The list of Component State objects which will * be attached to the element. */ function internalRender( forgoNode: ForgoNode | ForgoNode[], insertionOptions: NodeInsertionOptions, statesAwaitingAttach: NodeAttachedComponentState[], mountOnPreExistingDOM: boolean ): RenderResult { // Array of Nodes, or Fragment if (Array.isArray(forgoNode) || isForgoFragment(forgoNode)) { return renderArray( flatten(forgoNode), insertionOptions, statesAwaitingAttach, mountOnPreExistingDOM ); } // Primitive Nodes else if (!isForgoElement(forgoNode)) { return renderNonElement( forgoNode, insertionOptions, statesAwaitingAttach ); } // HTML Element else if (isForgoDOMElement(forgoNode)) { return renderDOMElement( forgoNode, insertionOptions, statesAwaitingAttach, mountOnPreExistingDOM ); } // Component else { const result = renderComponent( forgoNode, insertionOptions, statesAwaitingAttach, mountOnPreExistingDOM ); // In order to prevent issue #50 (Fragments having mount() called before // *all* child elements have finished rendering), we delay calling mount // until a subtree's render has completed // // Ideally this would encompass both mounts and unmounts, but an unmounted // component doesn't get `renderComponent()` called on it, so we need to // continue unmounting inside each of the type-specific render functions. // That's fine since the problem is elements not existing at mount time, // whereas unmount timing isn't sensitive to that. result.pendingMounts.forEach((fn) => fn()); result.pendingMounts.length = 0; return result; } } /* Render a string. * Such as in the render function below: * function MyComponent() { * return new forgo.Component({ * render() { * return "Hello world" * } * }) * } */ function renderNonElement( forgoNode: ForgoPrimitiveNode, insertionOptions: NodeInsertionOptions, statesAwaitingAttach: NodeAttachedComponentState[] ): RenderResult { // Text and comment nodes will always be recreated (why?). let node: ChildNode; if (forgoNode === null || forgoNode === undefined) { node = env.document.createComment("null component render"); } else { node = env.document.createTextNode(stringOfNode(forgoNode)); } let oldComponentState: NodeAttachedComponentState[] | undefined = undefined; // We have to find a node to replace. if (insertionOptions.type === "new-component") { const childNodes = insertionOptions.parentElement.childNodes; // If we're searching in a list, we replace if the current node is a text node. if (insertionOptions.length) { const targetNode = childNodes[insertionOptions.currentNodeIndex]; if ( targetNode.nodeType === TEXT_NODE_TYPE || targetNode.nodeType === COMMENT_NODE_TYPE ) { targetNode.replaceWith(node); oldComponentState = getForgoState(targetNode)?.components; } else { const nextNode = childNodes[insertionOptions.currentNodeIndex]; insertionOptions.parentElement.insertBefore(node, nextNode); } } // There are no target nodes available. else if ( childNodes.length === 0 || insertionOptions.currentNodeIndex === 0 ) { insertionOptions.parentElement.prepend(node); } else { const nextNode = childNodes[insertionOptions.currentNodeIndex]; insertionOptions.parentElement.insertBefore(node, nextNode); } } syncAttrsAndState(forgoNode, node, true, statesAwaitingAttach); unmountComponents(statesAwaitingAttach, oldComponentState); return { nodes: [node], pendingMounts: [ () => mountComponents(statesAwaitingAttach, oldComponentState), ], }; } /* Render a DOM element. Will find + update an existing DOM element (if appropriate), or insert a new element. Such as in the render function below: function MyComponent() { return { render() { return
Hello world
} } } */ function renderDOMElement( forgoElement: ForgoDOMElement, insertionOptions: NodeInsertionOptions, statesAwaitingAttach: NodeAttachedComponentState[], mountOnPreExistingDOM: boolean ): RenderResult { // We need to create a detached node if (insertionOptions.type === "detached") { return addElement(undefined, null); } // We have to find a node to replace. else { const childNodes = insertionOptions.parentElement.childNodes; if (insertionOptions.length) { const searchResult = findReplacementCandidateForElement( forgoElement, insertionOptions.parentElement, insertionOptions.currentNodeIndex, insertionOptions.length ); if (searchResult.found) { return renderExistingElement( searchResult.index, childNodes, insertionOptions ); } } return addElement( insertionOptions.parentElement, childNodes[insertionOptions.currentNodeIndex] ); } function renderChildNodes(parentElement: Element) { // If the user gave us exact HTML to stuff into this parent, we can // skip/ignore the usual rendering logic if (forgoElement.props.dangerouslySetInnerHTML) { parentElement.innerHTML = forgoElement.props.dangerouslySetInnerHTML.__html; } else { // Coerce children to always be an array, for simplicity const forgoChildren = flatten([forgoElement.props.children]).filter( // Children may or may not be specified (x) => x !== undefined && x !== null ); // Make sure that if the user prepends non-Forgo DOM children under this // parent that we start with the correct offset, otherwise we'll do DOM // transformations that don't make any sense for the given input. const firstForgoChildIndex = Array.from( parentElement.childNodes ).findIndex((child) => getForgoState(child)); // Each node we render will push any leftover children further down the // parent's list of children. After rendering everything, we can clean // up anything extra. We'll know what's extra because all nodes we want // to preserve come before this index. let lastRenderedNodeIndex = firstForgoChildIndex === -1 ? 0 : firstForgoChildIndex; for (const forgoChild of forgoChildren) { const { nodes: nodesAfterRender } = internalRender( forgoChild, { type: "new-component", parentElement, currentNodeIndex: lastRenderedNodeIndex, length: parentElement.childNodes.length - lastRenderedNodeIndex, }, [], mountOnPreExistingDOM ); // Continue down the children list to wherever's right after the stuff // we just added. Because users are allowed to add arbitrary stuff to // the DOM manually, we can't just jump by the count of rendered // elements, since that's the count of *managed* elements, which might // be interspersed with unmanaged elements that we also need to skip // past. if (nodesAfterRender.length) { while ( parentElement.childNodes[lastRenderedNodeIndex] !== nodesAfterRender[nodesAfterRender.length - 1] ) { lastRenderedNodeIndex += 1; } // Move the counter *past* the last node we inserted. E.g., if we just // inserted our first node, we need to increment from 0 -> 1, where // we'll start searching for the next thing we insert lastRenderedNodeIndex += 1; // If we're updating an existing DOM element, it's possible that the // user manually added some DOM nodes somewhere in the middle of our // managed nodes. If that happened, we need to scan forward until we // pass them and find the next managed node, which we'll use as the // starting point for whatever we render next. We still need the +1 // above to make sure we always progress the index, in case this is // our first render pass and there's nothing to scan forward to. while (lastRenderedNodeIndex < parentElement.childNodes.length) { if ( getForgoState(parentElement.childNodes[lastRenderedNodeIndex]) ) { break; } lastRenderedNodeIndex += 1; } } } // Remove all nodes that don't correspond to the rendered output of a // live component markNodesForUnloading( parentElement.childNodes, lastRenderedNodeIndex, parentElement.childNodes.length ); } } /** * If we're updating an element that was rendered in a previous render, * reuse the same DOM element. Just sync its children and attributes. */ function renderExistingElement( insertAt: number, childNodes: NodeListOf, insertionOptions: SearchableNodeInsertionOptions ): RenderResult { // Get rid of unwanted nodes. markNodesForUnloading( childNodes, insertionOptions.currentNodeIndex, insertAt ); const targetElement = childNodes[ insertionOptions.currentNodeIndex ] as Element; const oldComponentState = getForgoState(targetElement)?.components; syncAttrsAndState( forgoElement, targetElement, false, statesAwaitingAttach ); renderChildNodes(targetElement); unloadMarkedNodes(targetElement, statesAwaitingAttach); unmountComponents(statesAwaitingAttach, oldComponentState); return { nodes: [targetElement], pendingMounts: [ () => mountComponents(statesAwaitingAttach, oldComponentState), ], }; } function addElement( parentElement: Element | undefined, oldNode: ChildNode | null ): RenderResult { const newElement = createElement(forgoElement, parentElement); if (parentElement) { parentElement.insertBefore(newElement, oldNode); } if (forgoElement.props.ref) { forgoElement.props.ref.value = newElement; } syncAttrsAndState(forgoElement, newElement, true, statesAwaitingAttach); renderChildNodes(newElement); unmountComponents(statesAwaitingAttach, undefined); return { nodes: [newElement], pendingMounts: [() => mountComponents(statesAwaitingAttach, undefined)], }; } } /* Render a Component. Such as */ function renderComponent( forgoComponent: ForgoComponentElement, insertionOptions: NodeInsertionOptions, statesAwaitingAttach: NodeAttachedComponentState[], mountOnPreExistingDOM: boolean // boundary: ForgoComponent | undefined ): RenderResult { function renderExistingComponent( insertAt: number, childNodes: NodeListOf, insertionOptions: SearchableNodeInsertionOptions ): RenderResult { const targetNode = childNodes[insertAt]; const state = getExistingForgoState(targetNode); const componentState = state.components[componentIndex]; // Get rid of unwanted nodes. markNodesForUnloading( childNodes, insertionOptions.currentNodeIndex, insertAt ); if ( lifecycleEmitters.shouldUpdate( componentState.component, forgoComponent.props, componentState.props ) ) { // Since we have compatible state already stored, // we'll push the savedComponentState into pending states for later attachment. const updatedComponentState = { ...componentState, props: forgoComponent.props, }; // Get a new element by calling render on existing component. const newForgoNode = updatedComponentState.component.__internal.registeredMethods.render( forgoComponent.props, updatedComponentState.component ); const statesToAttach = statesAwaitingAttach.concat( updatedComponentState ); const previousNode = componentState.component.__internal.element.node; const boundary = updatedComponentState.component.__internal .registeredMethods.error ? updatedComponentState.component : undefined; const renderResult = withErrorBoundary( forgoComponent.props, statesToAttach, boundary, () => { // Create new node insertion options. const newInsertionOptions: NodeInsertionOptions = { type: "new-component", currentNodeIndex: insertionOptions.currentNodeIndex, length: updatedComponentState.nodes.length, parentElement: insertionOptions.parentElement, }; return renderComponentAndRemoveStaleNodes( newForgoNode, newInsertionOptions, statesToAttach, updatedComponentState, mountOnPreExistingDOM ); } ); lifecycleEmitters.afterRender( updatedComponentState.component, forgoComponent.props, previousNode ); return renderResult; } // shouldUpdate() returned false else { const indexOfNode = findNodeIndex( insertionOptions.parentElement.childNodes, componentState.component.__internal.element.node ); return { nodes: sliceNodes( insertionOptions.parentElement.childNodes, indexOfNode, indexOfNode + componentState.nodes.length ), pendingMounts: [], }; } } function addComponent(): RenderResult { const ctor = forgoComponent.type; const component = assertIsComponent(ctor, ctor(forgoComponent.props)); component.__internal.element.componentIndex = componentIndex; const boundary = component.__internal.registeredMethods.error ? component : undefined; // Create new component state // ... and push it to statesAwaitAttach[] const newComponentState: NodeAttachedComponentState = { key: forgoComponent.key, ctor, component, props: forgoComponent.props, nodes: [], isMounted: false, }; const statesToAttach = statesAwaitingAttach.concat(newComponentState); return withErrorBoundary( forgoComponent.props, statesToAttach, boundary, () => { // Create an element by rendering the component const newForgoElement = component.__internal.registeredMethods.render( forgoComponent.props, component ); // Create new node insertion options. const newInsertionOptions: NodeInsertionOptions = insertionOptions.type === "detached" ? insertionOptions : { type: "new-component", currentNodeIndex: insertionOptions.currentNodeIndex, length: mountOnPreExistingDOM ? insertionOptions.length : 0, parentElement: insertionOptions.parentElement, }; // Pass it on for rendering... const renderResult = internalRender( newForgoElement, newInsertionOptions, statesToAttach, mountOnPreExistingDOM ); // In case we rendered an array, set the node to the first node. // We do this because args.element.node would be set to the last node otherwise. newComponentState.nodes = renderResult.nodes; newComponentState.component.__internal.element.node = renderResult.nodes[0]; // No previousNode since new component. So just args and not // afterRenderArgs. lifecycleEmitters.afterRender( component, forgoComponent.props, undefined ); return renderResult; } ); } function withErrorBoundary( props: TProps, statesToAttach: NodeAttachedComponentState[], boundary: Component | undefined, exec: () => RenderResult ): RenderResult { try { return exec(); } catch (error) { if (boundary?.__internal.registeredMethods.error) { const newForgoElement = boundary.__internal.registeredMethods.error!( props, error, boundary ); return internalRender( newForgoElement, insertionOptions, statesToAttach, mountOnPreExistingDOM ); } else { throw error; } } } const componentIndex = statesAwaitingAttach.length; if ( // We need to create a detached node. insertionOptions.type !== "detached" && // We have to find a node to replace. insertionOptions.length && !mountOnPreExistingDOM ) { const childNodes = insertionOptions.parentElement.childNodes; const searchResult = findReplacementCandidateForComponent( forgoComponent, insertionOptions.parentElement, insertionOptions.currentNodeIndex, insertionOptions.length, statesAwaitingAttach.length ); if (searchResult.found) { return renderExistingComponent( searchResult.index, childNodes, insertionOptions ); } } // No nodes in target node list, or no matching node found. // Nothing to unload. return addComponent(); } function renderComponentAndRemoveStaleNodes( forgoNode: ForgoNode, insertionOptions: SearchableNodeInsertionOptions, statesToAttach: NodeAttachedComponentState[], componentState: NodeAttachedComponentState, mountOnPreExistingDOM: boolean ): RenderResult { const totalNodesBeforeRender = insertionOptions.parentElement.childNodes.length; // Pass it on for rendering... const renderResult = internalRender( forgoNode, insertionOptions, statesToAttach, mountOnPreExistingDOM ); const totalNodesAfterRender = insertionOptions.parentElement.childNodes.length; const numNodesReused = totalNodesBeforeRender + renderResult.nodes.length - totalNodesAfterRender; // Since we have re-rendered, we might need to delete a bunch of nodes from the previous render. // That list begins from currentIndex + num nodes in latest render. // Delete up to deleteFromIndex + componentState.nodes.length - numNodesReused, // in which componentState.nodes.length is num nodes from previous render. const deleteFromIndex = insertionOptions.currentNodeIndex + renderResult.nodes.length; const deletedNodes = markNodesForUnloading( insertionOptions.parentElement.childNodes, deleteFromIndex, deleteFromIndex + componentState.nodes.length - numNodesReused ); /* * transferredState is the state that's already been remounted on a different node. * Components in transferredState should not be unmounted, since this is already * being tracked on a different node. Hence transferredState needs to be removed * from deletedNodes. */ const transferredState = renderResult.nodes.length > 0 ? statesToAttach : []; // Patch state in deletedNodes to exclude what's been already transferred. for (const deletedNode of deletedNodes) { const state = getForgoState(deletedNode); if (state) { const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState( transferredState, state.components ); state.components = state.components.slice( indexOfFirstIncompatibleState ); } } // In case we rendered an array, set the node to the first node. // We do this because args.element.node would be set to the last node otherwise. componentState.nodes = renderResult.nodes; componentState.component.__internal.element.node = renderResult.nodes[0]; return renderResult; } /* Render an array of components. Called when a Component returns an array (or fragment) in its render method. */ function renderArray( forgoNodes: ForgoNode[], insertionOptions: NodeInsertionOptions, statesAwaitingAttach: NodeAttachedComponentState[], mountOnPreExistingDOM: boolean ): RenderResult { const flattenedNodes = flatten(forgoNodes); if (insertionOptions.type === "detached") { throw new Error( "Arrays and fragments cannot be rendered at the top level." ); } else { const renderResults: RenderResult = { nodes: [], pendingMounts: [] }; let currentNodeIndex = insertionOptions.currentNodeIndex; let numNodes = insertionOptions.length; for (const forgoNode of flattenedNodes) { const totalNodesBeforeRender = insertionOptions.parentElement.childNodes.length; const newInsertionOptions: SearchableNodeInsertionOptions = { ...insertionOptions, currentNodeIndex, length: numNodes, }; const renderResult = internalRender( forgoNode, newInsertionOptions, statesAwaitingAttach, mountOnPreExistingDOM ); renderResults.nodes.push(...renderResult.nodes); renderResults.pendingMounts.push(...renderResult.pendingMounts); const totalNodesAfterRender = insertionOptions.parentElement.childNodes.length; const numNodesRemoved = totalNodesBeforeRender + renderResult.nodes.length - totalNodesAfterRender; currentNodeIndex += renderResult.nodes.length; numNodes -= numNodesRemoved; } return renderResults; } } /** * This doesn't unmount components attached to these nodes, but moves the node * itself from the DOM to parentNode.__forgo_deletedNodes. We sort of "mark" * it for deletion, but it may be resurrected if it's matched by a keyed forgo * node that has been reordered. * * Nodes in between `from` and `to` (not inclusive of `to`) will be marked for * unloading. Use `unloadMarkedNodes()` to actually unload the nodes once * we're sure we don't need to resurrect them. * * We don't want to remove DOM nodes that aren't owned by Forgo. I.e., if the * user grabs a reference to a DOM element and manually adds children under * it, we don't want to remove those children. That'll mess up e.g., charting * libraries. */ function markNodesForUnloading( nodes: ArrayLike, from: number, to: number ): ChildNode[] { const justDeletedNodes: ChildNode[] = []; const nodesToRemove = sliceNodes(nodes, from, to); if (nodesToRemove.length) { const parentElement = nodesToRemove[0].parentElement as HTMLElement; const deletedNodes = getDeletedNodes(parentElement); for (const node of nodesToRemove) { // If the consuming application has manually mucked with the DOM don't // remove things it added const state = getForgoState(node); if (!state) continue; node.remove(); justDeletedNodes.push(node); deletedNodes.push({ node }); } } return justDeletedNodes; } /* Unmount components from nodes. We unmount only after first incompatible state, since compatible states will be reattached to new candidate node. */ function unloadMarkedNodes( parentElement: Element, statesAwaitingAttach: NodeAttachedComponentState[] ) { const deletedNodes = getDeletedNodes(parentElement); for (const { node } of deletedNodes) { const state = getForgoState(node); if (state) { state.deleted = true; const oldComponentStates = state.components; unmountComponents(statesAwaitingAttach, oldComponentStates); } } clearDeletedNodes(parentElement); } /* When states are attached to a new node or when states are reattached, some of the old component states need to go away. The corresponding components will need to be unmounted. While rendering, the component gets reused if the ctor is the same. If the ctor is different, the component is discarded. And hence needs to be unmounted. So we check the ctor type in old and new. */ function findIndexOfFirstIncompatibleState( newStates: NodeAttachedComponentState[], oldStates: NodeAttachedComponentState[] ): number { let i = 0; for (const newState of newStates) { if (oldStates.length > i) { const oldState = oldStates[i]; if (oldState.component !== newState.component) { break; } i++; } else { break; } } return i; } /** * Unmount components above an index. This is going to be passed a stale * state[]. * * The `unmount` lifecycle event will be called. */ function unmountComponents( statesAwaitingAttach: NodeAttachedComponentState[], oldComponentStates: NodeAttachedComponentState[] | undefined ) { if (!oldComponentStates) return; // If the parent has already unmounted, we can skip checks on children. let parentHasUnmounted = false; const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState( statesAwaitingAttach, oldComponentStates ); for ( let i = indexOfFirstIncompatibleState; i < oldComponentStates.length; i++ ) { const state = oldComponentStates[i]; const component = state.component; // Render if: // - parent has already unmounted // - OR for all nodes: // - node is disconnected // - OR node connected to a different component if ( parentHasUnmounted || state.nodes.every((x) => { if (!x.isConnected) { return true; } else { const stateOnCurrentNode = getExistingForgoState(x); return ( stateOnCurrentNode.components[i] === undefined || stateOnCurrentNode.components[i].component !== state.component ); } }) ) { if (!component.__internal.unmounted) { component.__internal.unmounted = true; lifecycleEmitters.unmount(component, state.props); } parentHasUnmounted = true; } } } /** * Mount components above an index. This is going to be passed the new * state[]. */ function mountComponents( statesAwaitingAttach: NodeAttachedComponentState[], oldComponentStates: NodeAttachedComponentState[] | undefined ) { const indexOfFirstIncompatibleState = oldComponentStates ? findIndexOfFirstIncompatibleState( statesAwaitingAttach, oldComponentStates ) : 0; for ( let i = indexOfFirstIncompatibleState; i < statesAwaitingAttach.length; i++ ) { const state = statesAwaitingAttach[i]; // This function is called in every syncStateAndProps() call, so many of // the calls will be for already-mounted components. Only fire the mount // lifecycle events when appropriate. if (!state.isMounted) { state.isMounted = true; // Set this before calling the lifecycle handlers to fix #70 lifecycleEmitters.mount(state.component, state.props); } } } type CandidateSearchResult = | { found: false; } | { found: true; index: number }; /** * When we try to find replacement candidates for DOM nodes, * we try to: * a) match by the key * b) match by the tagname */ function findReplacementCandidateForElement< TProps extends ForgoDOMElementProps >( forgoElement: ForgoDOMElement, parentElement: Element, searchFrom: number, length: number ): CandidateSearchResult { const nodes = parentElement.childNodes; for (let i = searchFrom; i < searchFrom + length; i++) { const node = nodes[i] as ChildNode; if (nodeIsElement(node)) { const stateOnNode = getForgoState(node); // If the user stuffs random elements into the DOM manually, we don't // want to treat them as replacement candidates - they should be left // alone. if (!stateOnNode) continue; if ( forgoElement.key !== undefined && stateOnNode?.key === forgoElement.key ) { return { found: true, index: i }; } else { // If the candidate has a key defined, // we don't match it with an unkeyed forgo element if ( node.tagName.toLowerCase() === forgoElement.type && (stateOnNode === undefined || stateOnNode.key === undefined) ) { return { found: true, index: i }; } } } } // Let's check deleted nodes as well. if (forgoElement.key !== undefined) { const deletedNodes = getDeletedNodes(parentElement); for (let i = 0; i < deletedNodes.length; i++) { const { node } = deletedNodes[i]; const stateOnNode = getForgoState(node); if (stateOnNode?.key === forgoElement.key) { // Remove it from deletedNodes. deletedNodes.splice(i, 1); // Append it to the beginning of the node list. const firstNodeInSearchList = nodes[searchFrom]; if (!isNullOrUndefined(firstNodeInSearchList)) { parentElement.insertBefore(node, firstNodeInSearchList); } else { parentElement.appendChild(node); } return { found: true, index: searchFrom }; } } } return { found: false }; } /** * When we try to find replacement candidates for Components, * we try to: * a) match by the key * b) match by the component constructor */ function findReplacementCandidateForComponent< TProps extends ForgoDOMElementProps >( forgoElement: ForgoComponentElement, parentElement: Element, searchFrom: number, length: number, componentIndex: number ): CandidateSearchResult { const nodes = parentElement.childNodes; for (let i = searchFrom; i < searchFrom + length; i++) { const node = nodes[i] as ChildNode; const stateOnNode = getForgoState(node); if (stateOnNode && stateOnNode.components.length > componentIndex) { if (forgoElement.key !== undefined) { if ( stateOnNode.components[componentIndex].ctor === forgoElement.type && stateOnNode.components[componentIndex].key === forgoElement.key ) { return { found: true, index: i }; } } else { if ( stateOnNode.components[componentIndex].ctor === forgoElement.type ) { return { found: true, index: i }; } } } } // Check if a keyed component is mounted on this node. function nodeBelongsToKeyedComponent( node: ChildNode, forgoElement: ForgoComponentElement, componentIndex: number ) { const stateOnNode = getForgoState(node); if (stateOnNode && stateOnNode.components.length > componentIndex) { if ( stateOnNode.components[componentIndex].ctor === forgoElement.type && stateOnNode.components[componentIndex].key === forgoElement.key ) { return true; } } return false; } // Let's check deleted nodes as well. if (forgoElement.key !== undefined) { const deletedNodes = getDeletedNodes(parentElement); for (let i = 0; i < deletedNodes.length; i++) { const { node: deletedNode } = deletedNodes[i]; if ( nodeBelongsToKeyedComponent(deletedNode, forgoElement, componentIndex) ) { const nodesToResurrect: ChildNode[] = [deletedNode]; // Found a match! // Collect all consecutive matching nodes. for (let j = i + 1; j < deletedNodes.length; j++) { const { node: subsequentNode } = deletedNodes[j]; if ( nodeBelongsToKeyedComponent( subsequentNode, forgoElement, componentIndex ) ) { nodesToResurrect.push(subsequentNode); } } // Remove them from deletedNodes. deletedNodes.splice(i, nodesToResurrect.length); // Append resurrected nodes to the beginning of the node list. const insertBeforeNode = nodes[searchFrom]; if (!isNullOrUndefined(insertBeforeNode)) { for (const node of nodesToResurrect) { parentElement.insertBefore(node, insertBeforeNode); } } else { for (const node of nodesToResurrect) { parentElement.appendChild(node); } } return { found: true, index: searchFrom }; } } } return { found: false }; } /** * Attach props from the forgoElement onto the DOM node. We also need to attach * states from statesAwaitingAttach */ function syncAttrsAndState( forgoNode: ForgoNode, node: ChildNode, isNewNode: boolean, statesAwaitingAttach: NodeAttachedComponentState[] ) { // We have to inject node into the args object. // components are already holding a reference to the args object. // They don't know yet that args.element.node is undefined. if (statesAwaitingAttach.length > 0) { statesAwaitingAttach[ statesAwaitingAttach.length - 1 ].component.__internal.element.node = node; } if (isForgoElement(forgoNode)) { const existingState = getForgoState(node); // Remove props which don't exist if (existingState && existingState.props) { for (const key in existingState.props) { if (!(key in forgoNode.props)) { if (key !== "children" && key !== "xmlns") { if ( node.nodeType === TEXT_NODE_TYPE || node.nodeType === COMMENT_NODE_TYPE ) { delete (node as any)[key]; } else if (node instanceof env.__internal.HTMLElement) { if (key in node) { delete (node as any)[key]; } else { (node as HTMLElement).removeAttribute(key); } } else { (node as Element).removeAttribute(key); } } } } } else { // A new node which doesn't have forgoState is SSR. // We have to manually extinguish props if (!isNewNode && nodeIsElement(node)) { if (node.hasAttributes()) { const attributes = Array.from(node.attributes); for (const attr of attributes) { const key = attr.name; if (!(key in forgoNode.props)) { node.removeAttribute(key); } } } } } // TODO: What preact does to figure out attr vs prop // - do a (key in element) check. const entries = Object.entries(forgoNode.props); for (const [key, value] of entries) { if (suppressedAttributes.includes(key)) continue; // The browser will sometimes perform side effects if an attribute is // set, even if its value hasn't changed, so only update attrs if // necessary. See issue #32. if (existingState?.props?.[key] !== value) { if (key !== "children" && key !== "xmlns") { if ( node.nodeType === TEXT_NODE_TYPE || node.nodeType === COMMENT_NODE_TYPE ) { (node as any)[key] = value; } else if (node instanceof env.__internal.HTMLElement) { if (key === "style") { // Optimization: many times in CSS to JS, style objects are re-used. // If they're the same, skip the expensive styleToString() call. if ( existingState === undefined || existingState.style === undefined || existingState.style !== forgoNode.props.style ) { const stringOfCSS = styleToString(forgoNode.props.style); if ((node as HTMLElement).style.cssText !== stringOfCSS) { (node as HTMLElement).style.cssText = stringOfCSS; } } } // This optimization is copied from preact. else if (key === "onblur") { (node as any)[key] = handlerDisabledOnNodeDelete(node, value); } else if (key in node) { (node as any)[key] = value; } else { (node as any).setAttribute(key, value); } } else { if (typeof value === "string") { (node as Element).setAttribute(key, value); } else { (node as any)[key] = value; } } } } } // Now attach the internal forgo state. const state: NodeAttachedState = { key: forgoNode.key, props: forgoNode.props, components: statesAwaitingAttach, }; setForgoState(node, state); } else { // Now attach the internal forgo state. const state: NodeAttachedState = { components: statesAwaitingAttach, }; setForgoState(node, state); } } /* Mount will render the DOM as a child of the specified container element. */ function mount( forgoNode: ForgoNode, container: Element | string | null ): RenderResult { const parentElement = ( isString(container) ? env.document.querySelector(container) : container ) as Element; if (isNullOrUndefined(parentElement)) { throw new Error( `The mount() function was called on a non-element (${ typeof container === "string" ? container : container?.tagName }).` ); } if (parentElement.nodeType !== ELEMENT_NODE_TYPE) { throw new Error( "The container argument to the mount() function should be an HTML element." ); } const mountOnPreExistingDOM = parentElement.childNodes.length > 0; const result = internalRender( forgoNode, { type: "new-component", currentNodeIndex: 0, length: parentElement.childNodes.length, parentElement, }, [], mountOnPreExistingDOM ); // Remove excess nodes. // This happens when there are pre-existing nodes. if (result.nodes.length < parentElement.childNodes.length) { const nodesToRemove = sliceNodes( parentElement.childNodes, result.nodes.length, parentElement.childNodes.length ); for (const node of nodesToRemove) { node.remove(); } } return result; } function unmount(container: Element | string | null) { const parentElement = ( isString(container) ? env.document.querySelector(container) : container ) as Element; if (isNullOrUndefined(parentElement)) { throw new Error( `The unmount() function was called on a non-element (${ typeof container === "string" ? container : container?.tagName }).` ); } if (parentElement.nodeType !== ELEMENT_NODE_TYPE) { throw new Error( "The container argument to the unmount() function should be an HTML element." ); } markNodesForUnloading( parentElement.childNodes, 0, parentElement.childNodes.length ); unloadMarkedNodes(parentElement, []); } /* This render function returns the rendered dom node. forgoNode is the node to render. */ function render(forgoNode: ForgoNode): { node: ChildNode; nodes: ChildNode[]; } { const renderResult = internalRender( forgoNode, { type: "detached", }, [], false ); return { node: renderResult.nodes[0], nodes: renderResult.nodes }; } /** * Code inside a component will call rerender whenever it wants to rerender. * The following function is what they'll need to call. * Given only a DOM element, how do we know what component to render? We'll * fetch all that information from the state information stored on the * element. * This is attached to a node inside a NodeAttachedState structure. * @param forceUnmount Allows a user to explicitly tear down a Forgo app from outside the framework */ function rerender( element: ForgoElementArg | undefined, props?: any ): RenderResult { if (!element?.node) { throw new Error(`Missing node information in rerender() argument.`); } const parentElement = element.node.parentElement; if (parentElement !== null) { const state = getExistingForgoState(element.node); const originalComponentState = state.components[element.componentIndex]; const effectiveProps = props ?? originalComponentState.props; if ( !lifecycleEmitters.shouldUpdate( originalComponentState.component, effectiveProps, originalComponentState.props ) ) { const indexOfNode = findNodeIndex( parentElement.childNodes, element.node ); return { nodes: sliceNodes( parentElement.childNodes, indexOfNode, indexOfNode + originalComponentState.nodes.length ), pendingMounts: [], }; } const componentStateWithUpdatedProps = { ...originalComponentState, props: effectiveProps, }; const parentStates = state.components.slice(0, element.componentIndex); const statesToAttach = parentStates.concat( componentStateWithUpdatedProps ); const previousNode = originalComponentState.component.__internal.element.node; const forgoNode = originalComponentState.component.__internal.registeredMethods.render( effectiveProps, originalComponentState.component ); const nodeIndex = findNodeIndex(parentElement.childNodes, element.node); const insertionOptions: SearchableNodeInsertionOptions = { type: "new-component", currentNodeIndex: nodeIndex, length: originalComponentState.nodes.length, parentElement, }; const renderResult = renderComponentAndRemoveStaleNodes( forgoNode, insertionOptions, statesToAttach, componentStateWithUpdatedProps, false ); // We have to propagate node changes up the component Tree. // Reason 1: // Imaging Parent rendering Child1 & Child2 // Child1 renders [div1, div2], and Child2 renders [div3, div4]. // When Child1's rerender is called, it might return [p1] instead of [div1, div2] // Now, Parent's node list (ie state.nodes) must be refreshed to [p1, div3, div4] from [div1, div2, div3, div4] // Reason 2: // If Child2 was rerendered (instead of Child1), attachProps() will incorrectly fixup parentState.element.node to div3, then to div4. // That's just how attachProps() works. We need to ressign parentState.element.node to p1. for (let i = 0; i < parentStates.length; i++) { const parentState = parentStates[i]; const indexOfOriginalRootNode = parentState.nodes.findIndex( (x) => x === originalComponentState.nodes[0] ); // Let's recreate the node list. parentState.nodes = parentState.nodes // 1. all the nodes before first node associated with rendered component. .slice(0, indexOfOriginalRootNode) // 2. newly created nodes. .concat(renderResult.nodes) // 3. nodes after last node associated with rendered component. .concat( parentState.nodes.slice( indexOfOriginalRootNode + originalComponentState.nodes.length ) ); // Fix up the root node for parent. if (parentState.nodes.length > 0) { // The root node might have changed, so fix it up just in case. parentState.component.__internal.element.node = parentState.nodes[0]; } } // Unload marked nodes. unloadMarkedNodes( parentElement, renderResult.nodes.length > 0 ? statesToAttach : [] ); // Unmount rendered component itself if all nodes are gone. // if (renderResult.nodes.length === 0) { // unmountComponents([newComponentState], 0); // } // Run afterRender() if defined. lifecycleEmitters.afterRender( originalComponentState.component, effectiveProps, previousNode ); return renderResult; } else { return { nodes: [], pendingMounts: [] }; } } function createElement( forgoElement: ForgoDOMElement<{ is?: string; xmlns?: string }>, parentElement: Element | undefined ) { const namespaceURI = forgoElement.props.xmlns !== undefined ? (forgoElement.props.xmlns as string) : forgoElement.type === "svg" ? SVG_NAMESPACE : parentElement ? parentElement.namespaceURI : null; if (forgoElement.props.is !== undefined) { return namespaceURI !== null ? env.document.createElementNS(namespaceURI, forgoElement.type, { is: forgoElement.props.is, }) : env.document.createElement(forgoElement.type, { is: forgoElement.props.is, }); } else { return namespaceURI !== null ? env.document.createElementNS(namespaceURI, forgoElement.type) : env.document.createElement(forgoElement.type); } } return { mount, unmount, render, rerender, }; } const windowObject = globalThis !== undefined ? globalThis : window; let forgoInstance = createForgoInstance({ window: windowObject, document: windowObject.document, }); export function setCustomEnv(customEnv: any) { forgoInstance = createForgoInstance(customEnv); } /** * Attach a new Forgo application to a DOM element */ export function mount( forgoNode: ForgoNode, container: Element | string | null ): RenderResult { return forgoInstance.mount(forgoNode, container); } /** * Unmount a Forgo application from outside. * @param container The root element that the Forgo app was mounted onto */ export function unmount(container: Element | string | null): void { return forgoInstance.unmount(container); } export function render(forgoNode: ForgoNode): { node: ChildNode; nodes: ChildNode[]; } { return forgoInstance.render(forgoNode); } export function rerender( element: ForgoElementArg | undefined, props?: any ): RenderResult { return forgoInstance.rerender(element, props); } /* This recursively flattens an array or a Fragment. Fragments are treated as arrays, with the children prop being array items. */ function flatten(itemOrItems: ForgoNode | ForgoNode[]): ForgoNode[] { function recurse( itemOrItems: ForgoNode | ForgoNode[], ret: ForgoNode[] = [] ) { const items = Array.isArray(itemOrItems) ? itemOrItems : isForgoFragment(itemOrItems) ? Array.isArray(itemOrItems.props.children) ? itemOrItems.props.children : !isNullOrUndefined(itemOrItems.props.children) ? [itemOrItems.props.children] : [] : [itemOrItems]; for (const entry of items) { if (Array.isArray(entry) || isForgoFragment(entry)) { recurse(entry, ret); } else { ret.push(entry); } } return ret; } return recurse(itemOrItems, []); } /** * ForgoNodes can be primitive types. Convert all primitive types to their * string representation. */ function stringOfNode(node: ForgoNonEmptyPrimitiveNode): string { return node.toString(); } /** * Get Node Types */ function isForgoElement(forgoNode: ForgoNode): forgoNode is ForgoElement { return ( forgoNode !== undefined && forgoNode !== null && (forgoNode as any).__is_forgo_element__ === true ); } function isForgoDOMElement(node: ForgoNode): node is ForgoDOMElement { return isForgoElement(node) && typeof node.type === "string"; } function isForgoFragment(node: ForgoNode): node is ForgoFragment { return node !== undefined && node !== null && (node as any).type === Fragment; } /* Get the state (NodeAttachedState) saved into an element. */ export function getForgoState(node: ChildNode): NodeAttachedState | undefined { return node.__forgo; } /* Same as above, but throws if undefined. (Caller must make sure.) */ function getExistingForgoState(node: ChildNode): NodeAttachedState { if (node.__forgo) { return node.__forgo; } else { throw new Error("Missing forgo state on node."); } } /* Sets the state (NodeAttachedState) on an element. */ export function setForgoState(node: ChildNode, state: NodeAttachedState): void { node.__forgo = state; } /* We maintain a list of deleted childNodes on an element. In case we need to resurrect it - on account of a subsequent out-of-order key referring that node. */ function getDeletedNodes(element: Element): DeletedNode[] { if (!element.__forgo_deletedNodes) { element.__forgo_deletedNodes = []; } return element.__forgo_deletedNodes; } function clearDeletedNodes(element: Element) { if (element.__forgo_deletedNodes) { element.__forgo_deletedNodes = []; } } /** * We bridge the old component syntax to the new syntax until our next breaking release */ export type ForgoLegacyComponent = { render: ( props: TProps & ForgoElementBaseProps, args: ForgoRenderArgs ) => ForgoNode | ForgoNode[]; afterRender?: ( props: TProps & ForgoElementBaseProps, args: ForgoAfterRenderArgs ) => void; error?: ( props: TProps & ForgoElementBaseProps, args: ForgoErrorArgs ) => ForgoNode; mount?: ( props: TProps & ForgoElementBaseProps, args: ForgoRenderArgs ) => void; unmount?: ( props: TProps & ForgoElementBaseProps, args: ForgoRenderArgs ) => void; shouldUpdate?: ( newProps: TProps & ForgoElementBaseProps, oldProps: TProps & ForgoElementBaseProps ) => boolean; __forgo?: { unmounted?: boolean }; }; export type ForgoRenderArgs = { element: ForgoElementArg; update: (props?: any) => RenderResult; }; export type ForgoAfterRenderArgs = ForgoRenderArgs & { previousNode?: ChildNode; }; export type ForgoErrorArgs = ForgoRenderArgs & { error: any; }; // We export this so forgo-state & friends can publish non-breaking // compatibility releases export const legacyComponentSyntaxCompat = ( legacyComponent: ForgoLegacyComponent ): Component => { const mkRenderArgs = (component: Component): ForgoRenderArgs => ({ get element() { return component.__internal.element; }, update(props) { return component.update(props as unknown as TProps); }, }); const componentBody: ForgoComponentMethods = { render(props, component) { return legacyComponent.render(props, mkRenderArgs(component)); }, }; if (legacyComponent.error) { componentBody.error = (props, error) => { return legacyComponent.error!( props, Object.assign(mkRenderArgs(component), { error }) ); }; } const component = new Component({ ...componentBody, }); if (legacyComponent.mount) { component.mount((props) => { legacyComponent.mount!(props, mkRenderArgs(component)); }); } if (legacyComponent.unmount) { component.unmount((props) => { legacyComponent.unmount!(props, mkRenderArgs(component)); }); } if (legacyComponent.afterRender) { component.afterRender((props, previousNode) => { legacyComponent.afterRender!( props, Object.assign(mkRenderArgs(component), { previousNode }) ); }); } if (legacyComponent.shouldUpdate) { component.shouldUpdate((newProps, oldProps) => { return legacyComponent.shouldUpdate!(newProps, oldProps); }); } return component; }; /* Throw if component is a non-component */ function assertIsComponent( ctor: ForgoNewComponentCtor | ForgoSimpleComponentCtor, component: Component | ForgoLegacyComponent ): Component { if (!(component instanceof Component) && Reflect.has(component, "render")) { return legacyComponentSyntaxCompat(component); } if (!(component instanceof Component)) { throw new Error( `${ ctor.name || "Unnamed" } component constructor must return an instance of the Component class` ); } return component; } function isNullOrUndefined( value: T | null | undefined ): value is null | undefined { return value === null || value === undefined; } function isString(val: unknown): val is string { return typeof val === "string"; } function nodeIsElement(node: ChildNode): node is Element { return node.nodeType === ELEMENT_NODE_TYPE; } // Thanks Artem Bochkarev function styleToString(style: any): string { if (typeof style === "string") { return style; } else if (style === undefined || style === null) { return ""; } else { return Object.keys(style).reduce( (acc, key) => acc + key .split(/(?=[A-Z])/) .join("-") .toLowerCase() + ":" + style[key] + ";", "" ); } } /** * node.childNodes is some funky data structure that's not really not an array, * so we can't just slice it like normal */ function sliceNodes( nodes: ArrayLike, from: number, to: number ): ChildNode[] { return Array.from(nodes).slice(from, to); } /** * node.childNodes is some funky data structure that's not really not an array, * so we can't just search for the value like normal */ function findNodeIndex( nodes: ArrayLike, element: ChildNode | undefined ): number { if (!element) return -1; return Array.from(nodes).indexOf(element); } /* JSX Types */ /* JSX typings expect a JSX namespace to be in scope for the forgo module (if a using a jsxFactory like forgo.createElement), or attached to the naked factory function (if using a jsxFactory like createElement). See: https://www.typescriptlang.org/docs/handbook/jsx.html#intrinsic-elements Also: https://dev.to/ferdaber/typescript-and-jsx-part-ii---what-can-create-jsx-22h6 Also: https://www.innoq.com/en/blog/type-checking-tsx/ Note that importing a module turns it into a namespace on this side of the import, so it doesn't need to be declared as a namespace inside jsxTypes.ts. However, attempting to declare it that way causes no end of headaches either when trying to reexport it here, or reexport it from a createElement namespace. Some errors arise at comple or build time, and some are only visible when a project attempts to consume forgo. */ // This covers a consuming project using the forgo.createElement jsxFactory export * as JSX from "./jsxTypes.js"; // If jsxTypes is imported using named imports, esbuild doesn't know how to // erase the imports and gets pset that "JSX" isn't an actual literal value // inside the jsxTypes.ts module. We have to import as a different name than the // export within createElement because I can't find a way to export a namespace // within a namespace without using import aliases. import * as JSXTypes from "./jsxTypes.js"; // The createElement namespace exists so that users can set their TypeScript // jsxFactory to createElement instead of forgo.createElement.// eslint-disable-next-line @typescript-eslint/no-namespace // eslint-disable-next-line @typescript-eslint/no-namespace export namespace createElement { // eslint-disable-next-line @typescript-eslint/no-unused-vars export import JSX = JSXTypes; } ================================================ FILE: src/jsxTypes.ts ================================================ import { ForgoDOMElementProps } from "."; /* JSX Definitions */ type Defaultize = // Distribute over unions Props extends any // Make any properties included in Default optional ? Partial>> & // Include the remaining properties from Props Pick> : never; export type LibraryManagedAttributes = Component extends { defaultProps: infer Defaults; } ? Defaultize : Props; export interface IntrinsicAttributes { key?: any; } export interface ElementAttributesProperty { props: any; } export interface ElementChildrenAttribute { children: any; } export type DOMCSSProperties = { [key in keyof Omit< CSSStyleDeclaration, | "item" | "setProperty" | "removeProperty" | "getPropertyValue" | "getPropertyPriority" >]?: string | number | null | undefined; }; export type AllCSSProperties = { [key: string]: string | number | null | undefined; }; export interface CSSProperties extends AllCSSProperties, DOMCSSProperties { cssText?: string | null; } export interface SVGAttributes extends HTMLAttributes { accentHeight?: number | string; accumulate?: "none" | "sum"; additive?: "replace" | "sum"; alignmentBaseline?: | "auto" | "baseline" | "before-edge" | "text-before-edge" | "middle" | "central" | "after-edge" | "text-after-edge" | "ideographic" | "alphabetic" | "hanging" | "mathematical" | "inherit"; allowReorder?: "no" | "yes"; alphabetic?: number | string; amplitude?: number | string; arabicForm?: "initial" | "medial" | "terminal" | "isolated"; ascent?: number | string; attributeName?: string; attributeType?: string; autoReverse?: number | string; azimuth?: number | string; baseFrequency?: number | string; baselineShift?: number | string; baseProfile?: number | string; bbox?: number | string; begin?: number | string; bias?: number | string; by?: number | string; calcMode?: number | string; capHeight?: number | string; clip?: number | string; clipPath?: string; clipPathUnits?: number | string; clipRule?: number | string; colorInterpolation?: number | string; colorInterpolationFilters?: "auto" | "sRGB" | "linearRGB" | "inherit"; colorProfile?: number | string; colorRendering?: number | string; contentScriptType?: number | string; contentStyleType?: number | string; cursor?: number | string; cx?: number | string; cy?: number | string; d?: string; decelerate?: number | string; descent?: number | string; diffuseConstant?: number | string; direction?: number | string; display?: number | string; divisor?: number | string; dominantBaseline?: number | string; dur?: number | string; dx?: number | string; dy?: number | string; edgeMode?: number | string; elevation?: number | string; enableBackground?: number | string; end?: number | string; exponent?: number | string; externalResourcesRequired?: number | string; fill?: string; fillOpacity?: number | string; fillRule?: "nonzero" | "evenodd" | "inherit"; filter?: string; filterRes?: number | string; filterUnits?: number | string; floodColor?: number | string; floodOpacity?: number | string; focusable?: number | string; fontFamily?: string; fontSize?: number | string; fontSizeAdjust?: number | string; fontStretch?: number | string; fontStyle?: number | string; fontVariant?: number | string; fontWeight?: number | string; format?: number | string; from?: number | string; fx?: number | string; fy?: number | string; g1?: number | string; g2?: number | string; glyphName?: number | string; glyphOrientationHorizontal?: number | string; glyphOrientationVertical?: number | string; glyphRef?: number | string; gradientTransform?: string; gradientUnits?: string; hanging?: number | string; horizAdvX?: number | string; horizOriginX?: number | string; ideographic?: number | string; imageRendering?: number | string; in2?: number | string; in?: string; intercept?: number | string; k1?: number | string; k2?: number | string; k3?: number | string; k4?: number | string; k?: number | string; kernelMatrix?: number | string; kernelUnitLength?: number | string; kerning?: number | string; keyPoints?: number | string; keySplines?: number | string; keyTimes?: number | string; lengthAdjust?: number | string; letterSpacing?: number | string; lightingColor?: number | string; limitingConeAngle?: number | string; local?: number | string; markerEnd?: string; markerHeight?: number | string; markerMid?: string; markerStart?: string; markerUnits?: number | string; markerWidth?: number | string; mask?: string; maskContentUnits?: number | string; maskUnits?: number | string; mathematical?: number | string; mode?: number | string; numOctaves?: number | string; offset?: number | string; opacity?: number | string; operator?: number | string; order?: number | string; orient?: number | string; orientation?: number | string; origin?: number | string; overflow?: number | string; overlinePosition?: number | string; overlineThickness?: number | string; paintOrder?: number | string; panose1?: number | string; pathLength?: number | string; patternContentUnits?: string; patternTransform?: number | string; patternUnits?: string; pointerEvents?: number | string; points?: string; pointsAtX?: number | string; pointsAtY?: number | string; pointsAtZ?: number | string; preserveAlpha?: number | string; preserveAspectRatio?: string; primitiveUnits?: number | string; r?: number | string; radius?: number | string; refX?: number | string; refY?: number | string; renderingIntent?: number | string; repeatCount?: number | string; repeatDur?: number | string; requiredExtensions?: number | string; requiredFeatures?: number | string; restart?: number | string; result?: string; rotate?: number | string; rx?: number | string; ry?: number | string; scale?: number | string; seed?: number | string; shapeRendering?: number | string; slope?: number | string; spacing?: number | string; specularConstant?: number | string; specularExponent?: number | string; speed?: number | string; spreadMethod?: string; startOffset?: number | string; stdDeviation?: number | string; stemh?: number | string; stemv?: number | string; stitchTiles?: number | string; stopColor?: string; stopOpacity?: number | string; strikethroughPosition?: number | string; strikethroughThickness?: number | string; string?: number | string; stroke?: string; strokeDasharray?: string | number; strokeDashoffset?: string | number; strokeLinecap?: "butt" | "round" | "square" | "inherit"; strokeLinejoin?: "miter" | "round" | "bevel" | "inherit"; strokeMiterlimit?: string | number; strokeOpacity?: number | string; strokeWidth?: number | string; surfaceScale?: number | string; systemLanguage?: number | string; tableValues?: number | string; targetX?: number | string; targetY?: number | string; textAnchor?: string; textDecoration?: number | string; textLength?: number | string; textRendering?: number | string; to?: number | string; transform?: string; u1?: number | string; u2?: number | string; underlinePosition?: number | string; underlineThickness?: number | string; unicode?: number | string; unicodeBidi?: number | string; unicodeRange?: number | string; unitsPerEm?: number | string; vAlphabetic?: number | string; values?: string; vectorEffect?: number | string; version?: string; vertAdvY?: number | string; vertOriginX?: number | string; vertOriginY?: number | string; vHanging?: number | string; vIdeographic?: number | string; viewBox?: string; viewTarget?: number | string; visibility?: number | string; vMathematical?: number | string; widths?: number | string; wordSpacing?: number | string; writingMode?: number | string; x1?: number | string; x2?: number | string; x?: number | string; xChannelSelector?: string; xHeight?: number | string; xlinkActuate?: string; xlinkArcrole?: string; xlinkHref?: string; xlinkRole?: string; xlinkShow?: string; xlinkTitle?: string; xlinkType?: string; xmlBase?: string; xmlLang?: string; xmlns?: string; xmlnsXlink?: string; xmlSpace?: string; y1?: number | string; y2?: number | string; y?: number | string; yChannelSelector?: string; z?: number | string; zoomAndPan?: string; } export interface PathAttributes { d: string; } export type TargetedEvent< Target extends EventTarget = EventTarget, TypedEvent extends Event = Event > = Omit & { readonly currentTarget: Target; }; export type TargetedAnimationEvent = TargetedEvent< Target, AnimationEvent >; export type TargetedClipboardEvent = TargetedEvent< Target, ClipboardEvent >; export type TargetedCompositionEvent = TargetedEvent< Target, CompositionEvent >; export type TargetedDragEvent = TargetedEvent< Target, DragEvent >; export type TargetedFocusEvent = TargetedEvent< Target, FocusEvent >; export type TargetedKeyboardEvent = TargetedEvent< Target, KeyboardEvent >; export type TargetedMouseEvent = TargetedEvent< Target, MouseEvent >; export type TargetedPointerEvent = TargetedEvent< Target, PointerEvent >; export type TargetedTouchEvent = TargetedEvent< Target, TouchEvent >; export type TargetedTransitionEvent = TargetedEvent< Target, TransitionEvent >; export type TargetedUIEvent = TargetedEvent< Target, UIEvent >; export type TargetedWheelEvent = TargetedEvent< Target, WheelEvent >; export type TargetedInputEvent = TargetedEvent< Target, InputEvent >; export type TargetedSecurityPolicyViolationEvent = TargetedEvent< Target, SecurityPolicyViolationEvent >; export interface EventHandler { /** * The `this` keyword always points to the DOM element the event handler * was invoked on. See: https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Event_handlers#Event_handlers_parameters_this_binding_and_the_return_value */ (this: E["currentTarget"], event: E): void; } export type AnimationEventHandler = EventHandler< TargetedAnimationEvent >; export type ClipboardEventHandler = EventHandler< TargetedClipboardEvent >; export type CompositionEventHandler = EventHandler< TargetedCompositionEvent >; export type DragEventHandler = EventHandler< TargetedDragEvent >; export type FocusEventHandler = EventHandler< TargetedFocusEvent >; export type GenericEventHandler = EventHandler< TargetedEvent >; export type KeyboardEventHandler = EventHandler< TargetedKeyboardEvent >; export type MouseEventHandler = EventHandler< TargetedMouseEvent >; export type PointerEventHandler = EventHandler< TargetedPointerEvent >; export type TouchEventHandler = EventHandler< TargetedTouchEvent >; export type TransitionEventHandler = EventHandler< TargetedTransitionEvent >; export type UIEventHandler = EventHandler< TargetedUIEvent >; export type WheelEventHandler = EventHandler< TargetedWheelEvent >; export type InputEventHandler = EventHandler< TargetedInputEvent >; export type SecurityPolicyViolationEventHandler = EventHandler< TargetedSecurityPolicyViolationEvent >; /* The function used to convert a.split("\n") .map(x => x.trim()) .map(x => !x.includes(":") ? x : x.split(":")[0].toLowerCase() + ":" + x.split(":")[1] ) .filter(x => !x.includes("capture?:")) .join("\n") */ export interface DOMAttributes extends ForgoDOMElementProps { // Image Events onload?: GenericEventHandler; onerror?: GenericEventHandler; // Clipboard Events oncopy?: ClipboardEventHandler; oncut?: ClipboardEventHandler; onpaste?: ClipboardEventHandler; // Composition Events oncompositionend?: CompositionEventHandler; oncompositionstart?: CompositionEventHandler; oncompositionupdate?: CompositionEventHandler; // Toggle Events onbeforetoggle?: GenericEventHandler; ontoggle?: GenericEventHandler; // Focus Events onfocus?: FocusEventHandler; onfocusin?: FocusEventHandler; onfocusout?: FocusEventHandler; onblur?: FocusEventHandler; // Input Events onbeforeinput?: InputEventHandler; oninput?: InputEventHandler; // Form Events oncancel?: GenericEventHandler; onchange?: GenericEventHandler; onsearch?: GenericEventHandler; onsubmit?: GenericEventHandler; oninvalid?: GenericEventHandler; onreset?: GenericEventHandler; onformdata?: GenericEventHandler; // Keyboard Events onkeydown?: KeyboardEventHandler; /** * @deprecated: This event is deprecated, use `onbeforeinput` or `onkeydown` instead. * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/keypress_event * */ onkeypress?: KeyboardEventHandler; onkeyup?: KeyboardEventHandler; // Media Events onabort?: GenericEventHandler; oncanplay?: GenericEventHandler; oncanplaythrough?: GenericEventHandler; ondurationchange?: GenericEventHandler; onemptied?: GenericEventHandler; onencrypted?: GenericEventHandler; onended?: GenericEventHandler; onloadeddata?: GenericEventHandler; onloadedmetadata?: GenericEventHandler; onloadstart?: GenericEventHandler; onpause?: GenericEventHandler; onplay?: GenericEventHandler; onplaying?: GenericEventHandler; onprogress?: GenericEventHandler; onratechange?: GenericEventHandler; onseeked?: GenericEventHandler; onseeking?: GenericEventHandler; onstalled?: GenericEventHandler; onsuspend?: GenericEventHandler; ontimeupdate?: GenericEventHandler; onvolumechange?: GenericEventHandler; onwaiting?: GenericEventHandler; // Drag Events ondrag?: DragEventHandler; ondragend?: DragEventHandler; ondragenter?: DragEventHandler; ondragleave?: DragEventHandler; ondragover?: DragEventHandler; ondragstart?: DragEventHandler; ondrop?: DragEventHandler; // Mouse Events ondblclick?: MouseEventHandler; onmousedown?: MouseEventHandler; onmouseenter?: MouseEventHandler; onmouseleave?: MouseEventHandler; onmousemove?: MouseEventHandler; onmouseout?: MouseEventHandler; onmouseover?: MouseEventHandler; onmouseup?: MouseEventHandler; // Selection Events onselect?: GenericEventHandler; // Touch Events ontouchcancel?: TouchEventHandler; ontouchend?: TouchEventHandler; ontouchmove?: TouchEventHandler; ontouchstart?: TouchEventHandler; // Pointer Events onauxclick?: PointerEventHandler; onclick?: PointerEventHandler; oncontextmenu?: PointerEventHandler; ongotpointercapture?: PointerEventHandler; onlostpointercapture?: PointerEventHandler; onpointerover?: PointerEventHandler; onpointerenter?: PointerEventHandler; onpointerdown?: PointerEventHandler; onpointermove?: PointerEventHandler; onpointerup?: PointerEventHandler; onpointercancel?: PointerEventHandler; onpointerout?: PointerEventHandler; onpointerleave?: PointerEventHandler; // Wheel Events onwheel?: WheelEventHandler; // Scroll Events onscroll?: GenericEventHandler; // Security Policy Violation Events onsecuritypolicyviolation?: SecurityPolicyViolationEventHandler; // Animation Events onanimationcancel?: AnimationEventHandler; onanimationend?: AnimationEventHandler; onanimationiteration?: AnimationEventHandler; onanimationstart?: AnimationEventHandler; // Transition Events ontransitioncancel?: TransitionEventHandler; ontransitionend?: TransitionEventHandler; ontransitionrun?: TransitionEventHandler; ontransitionstart?: TransitionEventHandler; } export interface HTMLAttributes // extends ForgoClassAttributes, extends DOMAttributes { [key: string]: any; // Standard HTML Attributes accept?: string; acceptcharset?: string; accesskey?: string; action?: string; allowfullscreen?: boolean; allowtransparency?: boolean; alt?: string; as?: string; async?: boolean; autocomplete?: string; autocorrect?: string; autofocus?: boolean; autoplay?: boolean; cellpadding?: number | string; cellspacing?: number | string; charset?: string; challenge?: string; checked?: boolean; class?: string; classname?: string; cols?: number; colspan?: number; content?: string; contenteditable?: boolean; contextmenu?: string; controls?: boolean; controlslist?: string; coords?: string; crossorigin?: string; data?: string; datetime?: string; default?: boolean; defer?: boolean; dir?: "auto" | "rtl" | "ltr"; disabled?: boolean; disableremoteplayback?: boolean; download?: any; draggable?: boolean; enctype?: string; form?: string; formaction?: string; formenctype?: string; formmethod?: string; formnovalidate?: boolean; formtarget?: string; frameborder?: number | string; headers?: string; height?: number | string; hidden?: boolean; high?: number; href?: string; hreflang?: string; for?: string; htmlfor?: string; httpequiv?: string; icon?: string; id?: string; inputmode?: string; integrity?: string; is?: string; keyparams?: string; keytype?: string; kind?: string; label?: string; lang?: string; list?: string; loading?: "eager" | "lazy"; loop?: boolean; low?: number; manifest?: string; marginheight?: number; marginwidth?: number; max?: number | string; maxlength?: number; media?: string; mediagroup?: string; method?: string; min?: number | string; minlength?: number; multiple?: boolean; muted?: boolean; name?: string; nonce?: string; novalidate?: boolean; open?: boolean; optimum?: number; pattern?: string; placeholder?: string; playsinline?: boolean; poster?: string; preload?: string; radiogroup?: string; readonly?: boolean; rel?: string; required?: boolean; role?: string; rows?: number; rowspan?: number; sandbox?: string; scope?: string; scoped?: boolean; scrolling?: string; seamless?: boolean; selected?: boolean; shape?: string; size?: number; sizes?: string; slot?: string; span?: number; spellcheck?: boolean; src?: string; srcset?: string; srcdoc?: string; srclang?: string; start?: number; step?: number | string; style?: string | CSSProperties; summary?: string; tabindex?: number; target?: string; title?: string; type?: string; usemap?: string; value?: string | string[] | number; volume?: string | number; width?: number | string; wmode?: string; wrap?: string; // RDFa Attributes about?: string; datatype?: string; inlist?: any; prefix?: string; property?: string; resource?: string; typeof?: string; vocab?: string; // Microdata Attributes itemprop?: string; itemscope?: boolean; itemtype?: string; itemid?: string; itemref?: string; } export interface HTMLMarqueeElement extends HTMLElement { behavior?: "scroll" | "slide" | "alternate"; bgcolor?: string; direction?: "left" | "right" | "up" | "down"; height?: number | string; hspace?: number | string; loop?: number | string; scrollamount?: number | string; scrolldelay?: number | string; truespeed?: boolean; vspace?: number | string; width?: number | string; } export type IntrinsicElements = { // We have to omit the SVG anchor element because both HTML and SVG have an // 'a' tag, and TSX gets confused about which one you mean if you have onclick // handlers. Sadly that means things get weird if you try to use the SVG // anchor tag, but I don't know how to support both. [el in keyof Omit]: HTMLAttributes< SVGElementTagNameMap[el] >; } & { [el in keyof HTMLElementTagNameMap]: HTMLAttributes< HTMLElementTagNameMap[el] >; }; ================================================ FILE: src/test/README.md ================================================ # Running Tests To run tests, cd into this directory ('tests'). And then: ```sh ./build.sh && npm test ``` ================================================ FILE: src/test/afterRender/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { counterX10, currentNode, previousNode, renderAgain, run, runWithTextNode, runWithRef, runWithDangerouslySetInnerHtml, } from "./script.js"; import should from "should"; export default function () { describe("runs afterRender()", () => { it("when mounted on a DOM element", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal(previousNode as Element, undefined); should.equal(counterX10, 10); should.equal((currentNode as Element).getAttribute("prop"), "hello"); renderAgain(); should.equal((previousNode as Element).nodeType, 1); should.equal(counterX10, 20); should.equal((currentNode as Element).getAttribute("prop"), "world"); should.equal((previousNode as Element).getAttribute("prop"), "hello"); renderAgain(); should.equal((previousNode as Element).nodeType, 1); should.equal(counterX10, 30); should.equal((currentNode as Element).getAttribute("prop"), "world"); should.equal((previousNode as Element).getAttribute("prop"), "world"); }); it("when mounted on a text node", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithTextNode(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal(previousNode as Element, undefined); should.equal(counterX10, 10); renderAgain(); should.equal((previousNode as Element).nodeType, 3); should.equal(counterX10, 20); renderAgain(); should.equal((previousNode as Element).nodeType, 3); should.equal(counterX10, 30); }); }); describe("setting an element's attributes", () => { it("skips the 'ref' attribute", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithRef(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal((currentNode as Element).getAttribute("ref"), undefined); }); it("skips the 'dangerouslySetInnerHtml' attribute", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithDangerouslySetInnerHtml(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal( (currentNode as Element).getAttribute("dangerouslySetInnerHTML"), undefined ); }); }); } ================================================ FILE: src/test/afterRender/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv, Component } from "../../index.js"; let window: DOMWindow; let document: Document; let component: Component<{}>; export function renderAgain() { component.update(); } export let currentNode: ChildNode | undefined; export let previousNode: ChildNode | undefined; export let counterX10: number; const TestComponent = () => { let counter: number = 0; component = new Component({ render() { counter++; return counter === 1 ? (
Hello world
) : (

Hello world

); }, }); component.afterRender((_props, previousNode_, component) => { currentNode = component.__internal.element.node; previousNode = previousNode_; counterX10 = counter * 10; }); return component; }; function ComponentOnTextNode() { let counter: number = 0; component = new Component({ render() { counter++; return "Hello world"; }, }); component.afterRender((_props, previousNode_, component) => { currentNode = component.__internal.element.node; previousNode = previousNode_; counterX10 = counter * 10; }); return component; } const ComponentWithRef = () => { const ref: forgo.ForgoRef = {}; const component = new forgo.Component({ render() { return
; }, }); component.afterRender((_props, _previousNode, component) => { currentNode = component.__internal.element.node; }); return component; }; const ComponentWithDangerouslySetInnerHTML = () => { const component = new forgo.Component({ render() { return
" }} />; }, }); component.afterRender((_props, _previousNode, component) => { currentNode = component.__internal.element.node; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } export function runWithTextNode(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } export function runWithRef(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } export function runWithDangerouslySetInnerHtml(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( , window.document.getElementById("root") ); }); } ================================================ FILE: src/test/assertIsComponent/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { componentError, run } from "./script.js"; export default function () { it("asserts if ctor returns a component", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); (componentError as Error).message.should.equal( "BasicComponent component constructor must return an instance of the Component class" ); }); } ================================================ FILE: src/test/assertIsComponent/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; function BasicComponent() { return
Hello world
; } export let componentError: any = undefined; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { try { mount(, document.getElementById("root")); } catch (ex) { componentError = ex; } }); } ================================================ FILE: src/test/boundary/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run } from "./script.js"; export default function () { it("honors error boundary", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql( "Error in ErrorComponent: Some error occurred :(" ); }); } ================================================ FILE: src/test/boundary/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { Component, mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const ErrorComponent = () => { return new forgo.Component({ render() { throw new Error("Some error occurred :("); }, }); }; interface ErrorBoundaryComponentProps extends forgo.ForgoElementBaseProps { name: string; } const ErrorBoundary = (props: ErrorBoundaryComponentProps) => { return new Component({ render({ children }) { return
{children}
; }, error(props, error) { return (

Error in {props.name}: {(error as Error).message}

); }, }); }; const App = () => { return new forgo.Component({ render() { return (
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/childWithFragmentUnmounts/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { numUnmounts, renderAgain, run } from "./script.js"; import should from "should"; export default function () { it("runs unmount on child returning a fragment", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); should.equal(numUnmounts, 1); }); } ================================================ FILE: src/test/childWithFragmentUnmounts/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let counter = 0; export let numUnmounts = 0; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } const TestComponent = () => { component = new forgo.Component({ render() { counter++; return counter === 1 ? :

1

; }, }); return component; }; const Child = () => { const component = new forgo.Component({ render() { return ( <>
1
2
3
); }, }); component.unmount(() => { numUnmounts++; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/clearsOldProps/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { renderAgain, run } from "./script.js"; export default function () { it("clears old props", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { renderAgain(); resolve(window.document.body.innerHTML); }); }); const elem = window.document.getElementById("mydiv"); should.not.exist((elem as Element).getAttribute("prop1")); should.equal((elem as Element).getAttribute("prop2"), "world"); }); } ================================================ FILE: src/test/clearsOldProps/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } const BasicComponent = () => { let firstRender = true; component = new forgo.Component({ render() { if (firstRender) { firstRender = false; return (
Hello world
); } else { return (
Hello world
); } }, }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/componentApi.tsx ================================================ import * as assert from "assert"; import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state: { component: forgo.Component | null; wrapper: forgo.Component | null; render: { key: string; args: [TestComponentProps, forgo.Component]; }[]; mount: { key: string; args: [TestComponentProps, forgo.Component]; }[]; unmount: { key: string; args: [TestComponentProps, forgo.Component]; }[]; afterRender: { key: string; args: [ TestComponentProps, ChildNode | undefined, forgo.Component ]; }[]; shouldUpdate: { key: string; args: [ TestComponentProps, TestComponentProps, forgo.Component ]; }[]; } = { component: null, wrapper: null, render: [], mount: [], unmount: [], afterRender: [], shouldUpdate: [], }; interface TestComponentProps { foo: string; forceUpdate?: boolean; } const TestComponent = (_props: TestComponentProps) => { const component = new forgo.Component({ render(...args) { state.render.push({ key: "render", args }); return

Hello, world!

; }, }); component.mount((...args) => state.mount.push({ key: "mount1", args })); component.mount((...args) => { state.mount.push({ key: "mount2", args }); component.unmount((...args) => state.unmount.push({ key: "unmount3", args }) ); }); component.unmount((...args) => state.unmount.push({ key: "unmount1", args }) ); component.unmount((...args) => state.unmount.push({ key: "unmount2", args }) ); component.afterRender((...args) => state.afterRender.push({ key: "afterRender1", args }) ); component.afterRender((...args) => state.afterRender.push({ key: "afterRender2", args }) ); component.shouldUpdate((...args) => { state.shouldUpdate.push({ key: "shouldUpdate1", args }); return false; }); component.shouldUpdate((...args) => { state.shouldUpdate.push({ key: "shouldUpdate2", args }); return args[0].forceUpdate ?? false; }); state.component = component; return component; }; interface WrapperProps extends TestComponentProps { unmount?: boolean; } /** * We use the wrapper to have a way for tests to unmount the actual component * we're testing */ const Wrapper = (props: WrapperProps) => { const wrapper = new forgo.Component({ render(props) { if (props.unmount) return

Unmounted!

; return ; }, }); state.wrapper = wrapper; return wrapper; }; return { Component: Wrapper, state: state, }; } export default function () { describe("The component API", () => { it("errors out if the ctor doesn't return a Component instance", async () => { const Ctor = () => ({}); await assert.rejects(run(() => )); }); it("passes the right arguments to render()", async () => { const { Component, state } = componentFactory(); await run((env) => ); should.equal(state.render.length, 1); should.equal(state.render[0].args[0].foo, "foo"); should.equal(state.render[0].args[1], state.component); }); it("passes the right arguments to mount event listeners", async () => { const { Component, state } = componentFactory(); await run((env) => ); should.equal(state.mount.length, 2); should.equal(state.mount[0].args[0].foo, "foo"); should.equal(state.mount[0].args[1], state.component); should.deepEqual( state.mount.map(({ key }) => key), ["mount1", "mount2"] ); }); it("passes the right arguments to afterRender event listeners", async () => { const { Component, state } = componentFactory(); await run((env) => ); should.equal(state.afterRender.length, 2); should.equal(state.afterRender[0].args[0].foo, "foo"); should.equal(state.afterRender[0].args[1], undefined); should.equal(state.afterRender[0].args[2], state.component); should.deepEqual( state.afterRender.map(({ key }) => key), ["afterRender1", "afterRender2"] ); }); it("passes the right arguments to shouldUpdate event listeners", async () => { const { Component, state } = componentFactory(); await run((env) => ); state.component!.update(); should.equal(state.shouldUpdate.length, 2); should.equal(state.shouldUpdate[0].args[0].foo, "foo"); should.equal(state.shouldUpdate[0].args[1].foo, "foo"); should.equal(state.shouldUpdate[0].args[2], state.component); should.deepEqual( state.shouldUpdate.map(({ key }) => key), ["shouldUpdate1", "shouldUpdate2"] ); }); it("only rerenders the component if at least one shouldUpdate listeners return true", async () => { const { Component, state } = componentFactory(); await run((env) => ); state.component!.update(); should.equal(state.render.length, 1); state.component!.update({ foo: "foo", forceUpdate: true }); should.equal(state.render.length, 2); }); it("passes the right arguments to unmount event listeners", async () => { const { Component, state } = componentFactory(); await run((env) => ); state.wrapper!.update({ foo: "foo", unmount: true }); // 3, sir! Because we want to catch not only the two top-level listeners, // but also the listener added by the mount event listener. should.equal(state.unmount.length, 3); should.equal(state.unmount[0].args[0].foo, "foo"); should.equal(state.unmount[0].args[1], state.component); should.deepEqual( state.unmount.map(({ key }) => key), ["unmount1", "unmount2", "unmount3"] ); }); }); describe("The legacy component API", () => { it("still works", async () => { interface Props { foo: number; } let mounted: Props | null = null; let unmounted: Props | null = null; let rendered: Props | null = null; let afterRender: Props | null = null; let shouldUpdate: Props | null = null; const LegacyComponent: forgo.ForgoSimpleComponentCtor = () => { return { mount(props) { mounted = props; }, unmount(props) { unmounted = props; }, render(props) { rendered = props; return

Hello, world!

; }, afterRender(props) { afterRender = props; }, shouldUpdate(props) { shouldUpdate = props; return true; }, }; }; interface ParentProps { renderChild: boolean; } let component: forgo.Component; const ParentComponent = (initialProps: ParentProps) => { component = new forgo.Component({ render({ renderChild }) { if (renderChild) return ; return null; }, }); return component; }; await run(() => ); // We have to render once just to render, then render again to kick // shouldUpdate, then render again to kick unmount component!.update(); component!.update({ renderChild: false }); should.deepEqual(mounted!.foo, 1); should.deepEqual(unmounted!.foo, 1); should.deepEqual(rendered!.foo, 1); should.deepEqual(afterRender!.foo, 1); should.deepEqual(shouldUpdate!.foo, 1); }); }); } ================================================ FILE: src/test/componentFragment/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run, runNested } from "./script.js"; import should from "should"; export default function () { describe("renders component returning fragments", () => { it("top level fragment", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); const rootElem = window.document.getElementById("root") as HTMLElement; rootElem.innerHTML.should.containEql( "
1
2
3
" ); }); it("nested fragment", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runNested(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); const rootElem = window.document.getElementById("root") as HTMLElement; rootElem.innerHTML.should.containEql( "
1
2
3
4
" ); }); }); } ================================================ FILE: src/test/componentFragment/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const TestComponent = () => { return new forgo.Component({ render() { return ( <>
1
2
3
); }, }); }; const NestedFragmentComponent = () => { return new forgo.Component({ render() { return ( <> <>
1
2
<>
3
4
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } export function runNested(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/componentKeepsStateWhenReordered/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run } from "./script.js"; import { componentStates, reorderComponents } from "./script.js"; export default function () { it("components maintain state when reordered", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const componentStatesFirstRender = await new Promise>( (resolve) => { window.addEventListener("load", () => { resolve(new Map(Array.from(componentStates))); }); } ); reorderComponents(); // We explicitly test with a falsey value (zero) to catch if we use the // shorthand `if (key)` rather than the required `if (key !== undefined)` [0, "1", "2", "3", "4", "5"].forEach((key) => { componentStates .get(key)! .should.equal( componentStatesFirstRender.get(key), `component with key=${key} state is mismatched` ); }); }); } ================================================ FILE: src/test/componentKeepsStateWhenReordered/script.tsx ================================================ import * as forgo from "../../index.js"; import { JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; function getRandomString() { return ( Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10) ); } export const componentStates = new Map(); interface StatefulComponentProps { key: unknown; } const StatefulComponent = () => { let state = getRandomString(); const component = new forgo.Component({ render({ key }) { componentStates.set(key, state); return (

Component #{key}

); }, }); component.unmount(({ key }) => { componentStates.delete(key); }); return component; }; let sortOrder = 1; let containerComponent: forgo.Component; export function reorderComponents() { sortOrder = 2; containerComponent.update({ key: undefined }); } const ContainerComponent = () => { containerComponent = new forgo.Component({ render() { componentStates.clear(); return (
{sortOrder === 1 ? ( <> ) : ( <> )}
); }, }); return containerComponent; }; export function run(dom: JSDOM) { const window = dom.window; const document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/componentMount.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; const componentFactory = () => { const state: { parentEl: forgo.ForgoRef; idAttr: string | null; parentChildrenCount: number; } = { parentEl: {}, parentChildrenCount: 0, idAttr: null }; const TestComponent = () => { const Child = () => { const component = new forgo.Component({ render() { return
Hello world
; }, }); component.mount(() => { state.idAttr = state.parentEl.value!.getAttribute("id"); }); return component; }; const component = new forgo.Component({ render() { return (
); }, }); component.mount(() => { state.parentChildrenCount = state.parentEl.value!.childNodes.length; }); return component; }; return { state, TestComponent }; }; const recursiveComponentFactory = () => { const state = { mountCount: 0, renderCount: 0, }; const TestComponent = () => { const component = new forgo.Component({ render() { state.renderCount += 1; return
; }, }); component.mount(() => { state.mountCount += 1; component.update(); }); return component; }; return { state, TestComponent }; }; export default function () { describe("Component mount event", async () => { it("runs mount() when a component is attached to node", async () => { const { state, TestComponent } = componentFactory(); await run(() => ); should.equal(state.parentEl.value!.id, "hello"); }); it("renders the parent's attributes before calling the child's mount()", async () => { const { state, TestComponent } = componentFactory(); await run(() => ); should.equal(state.idAttr, "hello"); }); it("renders all descendants before calling the parent's mount()", async () => { const { state, TestComponent } = componentFactory(); await run(() => ); should.equal(state.parentChildrenCount, 1); }); it("doesn't fire twice if the component updates during mount", async () => { const { state, TestComponent } = recursiveComponentFactory(); await run(() => ); should.equal(state.renderCount, 2); should.equal(state.mountCount, 1); }); }); } ================================================ FILE: src/test/componentRunner.tsx ================================================ import * as forgo from "../index.js"; import htmlFile from "./htmlFile.js"; import { DOMWindow, JSDOM } from "jsdom"; export interface ComponentEnvironment { window: DOMWindow; document: Document; } function defaultDom() { return new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); } /** * Receives a forgo component and renders it. * @param componentFactory Accepts some props to attach to the component and * returns something that will be passed directly to mount() * @param dom Defaults to creating a new JSDOM instance, but maybe for some * reason a test needs to create its own DOM? * @returns The constructed JSDOM globals, plus an object holding values the * component under test has exposed to the test * * We use a component factory instead of directly receiving the component * because we want tests to be able to set their own per-test props on a * component, which only works if the test declares the props as JSX */ export async function run( componentBuilder: (env: ComponentEnvironment) => { node: forgo.ForgoNode; }, dom: JSDOM = defaultDom() ): Promise<{ dom: JSDOM; document: Document; window: DOMWindow; }> { const window = dom.window; const document = window.document; forgo.setCustomEnv({ window, document }); const node = componentBuilder({ window, document }); // Wait for the component to actually render await new Promise((resolve, reject) => { window.addEventListener("load", () => { try { forgo.mount(node, document.getElementById("root")); resolve(); } catch (ex) { reject(ex); } }); }); return { dom, document, window }; } ================================================ FILE: src/test/componentUnmount.tsx ================================================ import should from "should"; import { DOMWindow, JSDOM } from "jsdom"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; import htmlFile from "./htmlFile.js"; const componentFactory = () => { const state = { parentUnmounts: 0, childUnmounts: 0, component: null as forgo.Component<{}> | null, }; const Parent = () => { let firstRender = true; state.component = new forgo.Component({ render() { if (firstRender) { firstRender = false; return ; } else { return
The child should have unmounted.
; } }, }); state.component.unmount(() => { state.parentUnmounts += 1; }); return state.component; }; const Child = () => { const component = new forgo.Component({ render() { return
This is the child component
; }, }); component.unmount(() => { state.childUnmounts += 1; }); return component; }; return { state, TestComponent: Parent }; }; export default function () { it("runs unmount() when a child component goes away", async () => { const { state, TestComponent } = componentFactory(); await run(() => ); state.component!.update(); should.equal(state.childUnmounts, 1); }); it("unmounts the component tree when forgo.unmount() is called", async () => { const { state, TestComponent } = componentFactory(); // Use a fragment to be sure we handle unmounting more than one root component const { document } = await run(() => ( <> )); forgo.unmount(document.getElementById("root")!); should.equal(state.parentUnmounts, 2); should.equal(state.childUnmounts, 2); should.equal(document.getElementById("root")!.childNodes.length, 0); }); } ================================================ FILE: src/test/css/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run } from "./script.js"; export default function () { it("applies css styles", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql( `
  • One
  • Two
` ); }); } ================================================ FILE: src/test/css/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const TestComponent = () => { return new forgo.Component({ render() { return (
  • One
  • Two
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/dangerouslySetInnerHTML/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run } from "./script.js"; export default function () { it("sets innerHTML if dangerouslySetInnerHTML is defined", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql("

Hello world

"); }); } ================================================ FILE: src/test/dangerouslySetInnerHTML/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const BasicComponent = () => { return new forgo.Component({ render() { return (
Hello world

` }}>
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/elementKeepsStateWhenReordered/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { inputRef1, inputRef2, inputRef3, inputRef4, inputRef5, run, } from "./script.js"; import { reorderElements } from "./script.js"; export default function () { it("element maintains state with reordered", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); reorderElements(); (inputRef1.value as HTMLInputElement).id.should.equal("inputnew1"); (inputRef2.value as HTMLInputElement).id.should.equal("inputnew2"); (inputRef3.value as HTMLInputElement).id.should.equal("inputnew3"); (inputRef4.value as HTMLInputElement).id.should.equal("inputnew4"); (inputRef5.value as HTMLInputElement).id.should.equal("inputnew5"); }); } ================================================ FILE: src/test/elementKeepsStateWhenReordered/script.tsx ================================================ import * as forgo from "../../index.js"; import { JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let sortOrder = 1; let component: forgo.Component<{}>; export function reorderElements() { sortOrder = 2; component.update({}); } export let inputRef1: forgo.ForgoRef = {}; export let inputRef2: forgo.ForgoRef = {}; export let inputRef3: forgo.ForgoRef = {}; export let inputRef4: forgo.ForgoRef = {}; export let inputRef5: forgo.ForgoRef = {}; const ContainerComponent = () => { component = new forgo.Component({ render() { return (
{sortOrder === 1 ? ( <> ) : ( <> )}
); }, }); return component; }; export function run(dom: JSDOM) { const window = dom.window; const document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/elementRef/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { inputRef, run } from "./script.js"; export default function () { it("attaches element refs", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal((inputRef.value as HTMLInputElement).tagName, "INPUT"); }); } ================================================ FILE: src/test/elementRef/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv, ForgoRef } from "../../index.js"; let window: DOMWindow; let document: Document; export let inputRef: ForgoRef = {}; const Parent = () => { return new forgo.Component({ render() { return (
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/fragmentMountEvent.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; import type { ForgoRef } from "../index.js"; // We should only call those pendingMounts after a component renders, not after // elements render. I guess? Or maybe, only after component || array renders? function componentFactory() { const state: { elementBoundAtMountTime: boolean | null; } = { elementBoundAtMountTime: null, }; const TestComponent = () => { const el: ForgoRef = {}; const component = new forgo.Component({ render(_props) { return ( <>

Ignore Me

Mount shouldn't fire until I'm created

); }, }); component.mount(() => { state.elementBoundAtMountTime = Boolean(el.value); }); return component; }; return { TestComponent, state, }; } export default function () { describe("Fragment mount event", () => { it("doesn't fire until *all* of the fragment's children have been created", async () => { const { TestComponent, state } = componentFactory(); await run(() => ); should.equal(state.elementBoundAtMountTime, true); }); }); } ================================================ FILE: src/test/fragmentOverwriteDoesNotUnmount/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { renderAgain, run, unmountCounter } from "./script.js"; import should from "should"; export default function () { it("does not unmount when fragment is overwritten", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); renderAgain(); should.equal(unmountCounter, 0); window.document.body.innerHTML.should.containEql( "

5

6

7

" ); }); } ================================================ FILE: src/test/fragmentOverwriteDoesNotUnmount/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let counter = 0; let component: forgo.Component<{}>; export let unmountCounter: number = 0; export function renderAgain() { component.update(); } const TestComponent = () => { component = new forgo.Component({ render() { counter++; return counter === 1 ? ( <>
1
2
3
) : counter === 2 ? (

4

) : ( <>

5

6

7

); }, }); component.unmount(() => { unmountCounter++; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/fragmentUnmountRunsOnce/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { renderAgain, run, unmountCounter } from "./script.js"; import should from "should"; export default function () { it("runs fragment unmount only once", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); should.equal(unmountCounter, 0); }); } ================================================ FILE: src/test/fragmentUnmountRunsOnce/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let counter = 0; let component: forgo.Component<{}>; export let unmountCounter: number = 0; export function renderAgain() { component.update(); } const TestComponent = () => { component = new forgo.Component({ render() { counter++; return counter === 1 ? ( <>
1
2
3
) : ( <>

1

2

3

); }, }); component.unmount(() => { unmountCounter++; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/htmlFile.ts ================================================ export default function html(prerendered?: string) { return ` Zerok Test
${prerendered || ""}
`; } ================================================ FILE: src/test/hydrate/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { buttonRef, run } from "./script.js"; export default function () { it("hydrates", async () => { const dom = new JSDOM( htmlFile(`

Clicked 0 times

`), { runScripts: "outside-only", resources: "usable", } ); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); (buttonRef as any).value.click(); (buttonRef as any).value.click(); (buttonRef as any).value.click(); window.document.body.innerHTML.should.containEql("Clicked 3 times"); }); } ================================================ FILE: src/test/hydrate/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { render, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; export let buttonRef: any = {}; const TestComponent = () => { let counter = 0; return new forgo.Component({ render(_props: any, component) { function updateCounter() { counter++; component.update(); } return (

Clicked {counter} times

); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); const { node } = render(); window.addEventListener("load", () => { document.getElementById("root")!.firstElementChild!.replaceWith(node); }); } ================================================ FILE: src/test/inheritedCustomElement/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run } from "./script.js"; export default function () { it("works with inherited custom elements", async () => { let calledConnectedCallback = false; const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; class WordCount extends window.HTMLParagraphElement { connectedCallback() { calledConnectedCallback = true; // count words in element's parent element let wcParent = this.parentNode; function countWords(node: any) { let text = node.innerText || node.textContent; return text.split(/\s+/g).length; } let count = "Words: " + countWords(wcParent); // Create a shadow root let shadow = this.attachShadow({ mode: "open" }); // Create text node and add word count to it let text = window.document.createElement("span"); text.textContent = count; // Append it to the shadow root shadow.appendChild(text); } } window.customElements.define("word-count", WordCount, { extends: "p" }); run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); calledConnectedCallback.should.be.true(); }); } ================================================ FILE: src/test/inheritedCustomElement/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const TestComponent = () => { return new forgo.Component({ render() { return (

Sample heading

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc pulvinar sed justo sed viverra. Aliquam ac scelerisque tellus. Vivamus porttitor nunc vel nibh rutrum hendrerit. Donec viverra vestibulum pretium. Mauris at eros vitae ante pellentesque bibendum. Etiam et blandit purus, nec aliquam libero. Etiam leo felis, pulvinar et diam id, sagittis pulvinar diam. Nunc pellentesque rutrum sapien, sed faucibus urna sodales in. Sed tortor nisl, egestas nec egestas luctus, faucibus vitae purus. Ut elit nunc, pretium eget fermentum id, accumsan et velit. Sed mattis velit diam, a elementum nunc facilisis sit amet.

); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/keyedFragmentsPreserveChildStates/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { renderAgain, run } from "./script.js"; import should from "should"; export default function () { /** * If a keyed component reterns a Fragment, the states of all children of the * fragment should be preserved upon rerender. */ it("keyed Fragments preserve all children", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); const getStates = (targets: "swappable" | "fixed-position") => { const els = Array.from( window.document.getElementsByClassName("stateful-grandchild") ); const targetEls = targets === "swappable" ? els.filter((el) => { const attr = el.getAttribute("data-key"); return attr === "first-child" || attr === "second-child"; }) : [els[els.length - 1]]; const found = targetEls.map((el) => el.getAttribute("data-state")); // Sanity check, because the tests pass against an empty array // TODO: search by key, not class if (found.length === 0) { throw new Error("Should have found elements"); } return found; }; // Capture the original states const grandchildrenStatePass1 = getStates("swappable"); renderAgain(); const grandchildrenStatePass2 = getStates("swappable"); should.deepEqual( grandchildrenStatePass2.reverse(), grandchildrenStatePass1, "Grandchildren states should be exactly reversed from the first render" ); renderAgain(); const grandchildrenStatePass3 = getStates("swappable"); should.deepEqual( grandchildrenStatePass3, grandchildrenStatePass1, "Grandchildren states should be identical to the first render" ); }); } ================================================ FILE: src/test/keyedFragmentsPreserveChildStates/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; export function renderAgain() { elementOrder = !elementOrder; component.update(); } let component: forgo.Component<{}>; let elementOrder = true; const Parent = () => { component = new forgo.Component({ render() { const keys = elementOrder ? ["first-child", "second-child"] : ["second-child", "first-child"]; return ( <> ); }, }); return component; }; interface ChildProps { key?: unknown; } const Child = () => { const state = Math.random().toString(); return new forgo.Component({ render(props) { return ( <>

Hello, world!

{props.key ? : null} ); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/mount/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { run, runQuerySelector } from "./script.js"; export default function () { describe("mounts a component", () => { it("mounts on an DOM element", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql("Hello world"); }); it("mounts using query selector", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runQuerySelector(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql("Hello world"); }); }); } ================================================ FILE: src/test/mount/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const BasicComponent = () => { return new forgo.Component({ render() { return
Hello world
; }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } export function runQuerySelector(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, "#root"); }); } ================================================ FILE: src/test/mountRunsOnceWhenChildRendersFragment/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { mountCounter, renderAgain, run } from "./script.js"; import should from "should"; export default function () { it("runs mount only once when child renders fragment", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); should.equal(mountCounter, 1); }); } ================================================ FILE: src/test/mountRunsOnceWhenChildRendersFragment/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let counter = 0; let component: forgo.Component<{}>; export let mountCounter: number = 0; export function renderAgain() { component.update(); } const TestComponent = () => { component = new forgo.Component({ render() { counter++; return ; }, }); component.mount(() => { mountCounter++; }); return component; }; const SuperCompo = () => { return new forgo.Component({ render() { return counter === 1 ? ( <>
1
2
3
) : ( <>

1

2

3

); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/mountRunsOnceWhenRenderingFragment/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { mountCounter, renderAgain, run } from "./script.js"; import should from "should"; export default function () { it("runs mount only once when rendering fragment", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); should.equal(mountCounter, 1); }); } ================================================ FILE: src/test/mountRunsOnceWhenRenderingFragment/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let counter = 0; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } export let mountCounter = 0; const TestComponent = () => { component = new forgo.Component({ render() { counter++; return counter === 1 ? ( <>
1
2
3
) : ( <>

1

2

3

); }, }); component.mount(() => { mountCounter++; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/nodeState/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { ForgoRef } from "../../index.js"; import { run } from "./script.js"; export default function () { it("attaches state correctly", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise>((resolve) => { window.addEventListener("load", () => { resolve(window.myInput); }); }); const greetingDiv = window.greetingDiv.value; should.exist(greetingDiv.__forgo); should.equal(greetingDiv.__forgo.components.length, 2); should.equal(greetingDiv.__forgo.key, "mydiv"); should.equal( greetingDiv.__forgo.components[0].component.__internal.element .componentIndex, 0 ); should.equal( greetingDiv.__forgo.components[1].component.__internal.element .componentIndex, 1 ); }); } ================================================ FILE: src/test/nodeState/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const Parent = () => { return new forgo.Component({ render() { return ; }, }); }; interface GreetProps { name: string; } const Greet = (props: GreetProps) => { window.greetingDiv = {}; return new forgo.Component({ render(props) { return (
Hello {props.name}
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/package1.json ================================================ { "name": "forgo-test-suite", "version": "1.4.9", "private": "true" } ================================================ FILE: src/test/passProps/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { run } from "./script.js"; export default function () { it("passes props to child", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql("Hello"); }); } ================================================ FILE: src/test/passProps/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const Parent = () => { return new forgo.Component({ render() { return (
); }, }); }; interface GreetProps { text: string; } const Greet = (_initialProps: GreetProps) => { return new forgo.Component({ render(props: { text: string }) { return
{props.text}
; }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/propsChanges/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { mutatedProps, renderAgain, run } from "./script.js"; import should from "should"; export default function () { describe("props changes", () => { it("doesn't set props if they haven't changed", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal(mutatedProps["id"], undefined); should.equal(mutatedProps["x-id"], undefined); should.equal(mutatedProps["prop"], undefined); renderAgain(); // We have to give the event loop a chance to run, because our test will // run synchronously before the MutationObserver fires and records any // changes await new Promise((resolve) => queueMicrotask(() => resolve(null))); should.equal( mutatedProps["id"], undefined, "id prop should not have been mutated" ); should.equal( mutatedProps["x-id"], undefined, "x-id attribute should not have been mutated" ); // This is a canary. If this fails, it means our MutationObserver wasn't // set up correctly, and we can't trust the prior assertions. should.equal( mutatedProps["prop"], true, "MutationObserver was not set up correctly" ); }); }); } ================================================ FILE: src/test/propsChanges/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } export let currentNode: Element | undefined; export let previousNode: Element | undefined; export let counterX10 = 0; export let mutatedProps: { [key: string]: boolean; } = {}; const TestComponent = () => { let counter: number = 0; const el: forgo.ForgoRef = {}; mutatedProps = {}; component = new forgo.Component({ render() { counter++; return (
Hello world
); }, }); component.mount(() => { // Detect each time attributes are changed (after the first render) const observer = new window.MutationObserver((mutations) => { const elMutation = mutations.find( (mutation) => mutation.target === el.value ); if (elMutation?.attributeName) { mutatedProps[elMutation.attributeName] = true; } }); observer.observe(el.value!, { attributes: true }); }); component.afterRender((_props: any, previousNode, component) => { currentNode = component.__internal.element.node as Element; previousNode = previousNode as Element; counterX10 = counter * 10; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/renderPrimitives/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { Wrapping, runWithBooleanProps, runWithNullProps, runWithNumericProps, runWithStringProps, runWithUndefinedProps, } from "./script.js"; const TEXT_NODE_TYPE = 3; export default function () { describe("renders primitives", () => { const wrapping: Wrapping[] = ["DIV", "FRAGMENT", "NONE"]; wrapping.forEach((wrapping) => { const wrappedText = wrapping === "DIV" ? " wrapped in DIV" : wrapping === "FRAGMENT" ? " wrapped in FRAGMENT" : " without wrapping"; it("renders undefined" + wrappedText, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithUndefinedProps(dom, wrapping); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); if (wrapping === "DIV") { should.equal( window.document.getElementById("mydiv")?.childNodes.length, 0 ); } else { should.equal( window.document.getElementById("root")?.childNodes.length, 1 ); should.equal( window.document.getElementById("root")?.childNodes[0].nodeType, 8 ); } }); it("renders null" + wrappedText, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithNullProps(dom, wrapping); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); if (wrapping === "DIV") { should.equal( window.document.getElementById("mydiv")?.childNodes.length, 0 ); } else { should.equal( window.document.getElementById("root")?.childNodes.length, 1 ); should.equal( window.document.getElementById("root")?.childNodes[0].nodeType, 8 ); } }); it("renders string" + wrappedText, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithStringProps(dom, wrapping); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); window.document .getElementById("mydiv") ?.innerHTML.should.equal("hello"); }); it("renders boolean" + wrappedText, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithBooleanProps(dom, wrapping); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); window.document.getElementById("mydiv")?.innerHTML.should.equal("true"); }); it("renders number" + wrappedText, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runWithNumericProps(dom, wrapping); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); window.document.getElementById("mydiv")?.innerHTML.should.equal("100"); }); }); }); } ================================================ FILE: src/test/renderPrimitives/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; type ComponentProps = { value: string | number | boolean | object | null | BigInt | undefined; }; export type Wrapping = "DIV" | "NONE" | "FRAGMENT"; const ComponentReturningWrappedPrimitive = (_props: ComponentProps) => { return new forgo.Component({ render(props: ComponentProps) { return
{props.value}
; }, }); }; const ComponentReturningPrimitive = (_props: ComponentProps) => { return new forgo.Component({ render(props: ComponentProps) { return props.value; }, }); }; const ComponentReturningPrimitiveInFragment = (_props: ComponentProps) => { return new forgo.Component({ render(props: ComponentProps) { return <>{props.value}; }, }); }; export function runWithUndefinedProps(dom: JSDOM, wrapping: Wrapping) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( wrapping === "DIV" ? ( ) : wrapping === "FRAGMENT" ? ( ) : ( ), document.getElementById("root") ); }); } export function runWithNullProps(dom: JSDOM, wrapping: Wrapping) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( wrapping === "DIV" ? ( ) : wrapping === "FRAGMENT" ? ( ) : ( ), "#root" ); }); } export function runWithStringProps(dom: JSDOM, wrapping: Wrapping) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( wrapping === "DIV" ? ( ) : wrapping === "FRAGMENT" ? ( ) : ( ), "#root" ); }); } export function runWithBooleanProps(dom: JSDOM, wrapping: Wrapping) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( wrapping === "DIV" ? ( ) : wrapping === "FRAGMENT" ? ( ) : ( ), "#root" ); }); } export function runWithNumericProps(dom: JSDOM, wrapping: Wrapping) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( wrapping === "DIV" ? ( ) : wrapping === "FRAGMENT" ? ( ) : ( ), "#root" ); }); } ================================================ FILE: src/test/rendersArraysInChildren/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { runArrays, runNestedArrays } from "./script.js"; export default function () { describe("renders arrays as DOM node children", () => { const tests: [string, (dom: JSDOM) => void][] = [ ["array", runArrays], ["nested array", runNestedArrays], ]; tests.forEach(([testName, run]) => { it(testName, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); const innerHtml = await new Promise((resolve) => { window.addEventListener("load", () => { resolve(window.document.body.innerHTML); }); }); innerHtml.should.containEql( "
Hello world

1

2

3

4

" ); }); }); }); } ================================================ FILE: src/test/rendersArraysInChildren/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; const someIntegers = [1, 2, 3, 4]; const BasicComponent = () => { return new forgo.Component({ render() { return (
Hello world {someIntegers.map((i) => (

{i}

))}
); }, }); }; export function runArrays(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } const BasicComponentNested = () => { return new forgo.Component({ render() { return (
Hello world {someIntegers.map((i) => [[[

{i}

]]])}
); }, }); }; export function runNestedArrays(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/replaceByKey/index.ts ================================================ import { DOMWindow, JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import should from "should"; import { renderAgain, runObjectKey, runStringKey, unmountedElements, } from "./script.js"; export default function () { describe("replacement by key", () => { const tests: [string, (dom: JSDOM) => void][] = [ ["string key", runStringKey], ["object key", runObjectKey], ]; tests.forEach(([testName, run]) => { it(`replaces a child by ${testName}`, async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); window.document.body.innerHTML.should.containEql("Hello 2X"); should.deepEqual(unmountedElements, ["1", "3"]); }); }); }); } ================================================ FILE: src/test/replaceByKey/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; type ParentProps = { keys: { key: any; id: string; }[]; }; export let unmountedElements: string[] = []; let component: forgo.Component; export function renderAgain() { component.update(); } const Parent = (props: ParentProps) => { unmountedElements = []; let firstRender = true; component = new forgo.Component({ render(props) { if (firstRender) { firstRender = false; return (
{props.keys.map((k) => ( ))}
); } else { return (
); } }, }); return component; }; interface ChildProps { key: any; id: string; } const Child = (props: ChildProps) => { let myId = "NA"; const component = new forgo.Component({ render(props) { myId = props.id; return
Hello {props.id}
; }, }); component.unmount(() => { unmountedElements.push(myId); }); return component; }; export function runStringKey(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount( , document.getElementById("root") ); }); } export function runObjectKey(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); const keyOne = { x: 1 }; const keyTwo = { x: 2 }; const keyThree = { x: 3 }; window.addEventListener("load", () => { mount( , document.getElementById("root") ); }); } ================================================ FILE: src/test/replacingFragmentWithNodeWontUnmount/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { run, unmountCounter, renderAgain } from "./script.js"; import should from "should"; export default function () { it("runs unmount when fragment is replaced with a node", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); renderAgain(); should.equal(unmountCounter, 0); }); } ================================================ FILE: src/test/replacingFragmentWithNodeWontUnmount/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let counter = 0; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } export let unmountCounter = 0; const TestComponent = () => { component = new forgo.Component({ render() { counter++; return counter === 1 ? ( <>
1
2
3
) : (

1

); }, }); component.unmount(() => { unmountCounter++; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/rerender.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; import type { ForgoRef } from "../index.js"; import { DOMWindow } from "jsdom"; const unmanagedNodeTagName = "article"; function componentFactory() { const state: { buttonElement: ForgoRef; rootElement: ForgoRef; subrootElement: ForgoRef; pElement: ForgoRef; remove: () => void; } = { buttonElement: {}, rootElement: {}, subrootElement: {}, pElement: {}, remove: () => {}, }; interface TestComponentProps { insertionPosition?: "first" | "last" | number; window: DOMWindow; document: Document; } const TestComponent = (props: TestComponentProps) => { let counter = 0; let insertAfterRender = true; const { insertionPosition, document } = props; const addChild = (el: HTMLElement, tag: string) => { if (!insertionPosition) return; // We create an
so that it's very obvious if a bug causes forgo to // treat the node as a managed dom node and transform its props/children const newElement = document.createElement(unmanagedNodeTagName); newElement.setAttribute("data-forgo", tag); switch (insertionPosition) { case "first": el.prepend(newElement); break; case "last": el.append(newElement); break; default: el.insertBefore( newElement, Array.from(el.childNodes)[insertionPosition] ); } }; const component = new forgo.Component({ render(_props, component) { function updateCounter() { counter++; component.update(); } state.remove = () => { state.rootElement .value!.querySelectorAll("[data-forgo]") .forEach((el) => el.remove()); // The test that uses this asserts that all these nodes are gone, so // don't add one after we call update(). We have to call update() to // know that Forgo doesn't misbehave when nodes it saw before disappear. insertAfterRender = false; component.update(); }; return (

Clicked {counter} times

); }, }); component.afterRender(() => { if (!insertAfterRender) return; addChild(state.rootElement.value!, "child-of-root"); if (insertionPosition === "first") { addChild(state.subrootElement.value!, "child-of-subroot"); } }); return component; }; return { Component: TestComponent, state, }; } export default function () { describe("rerendering", () => { it("rerenders", async () => { const { Component, state: { buttonElement }, } = componentFactory(); const { window } = await run((env) => ); buttonElement.value!.click(); buttonElement.value!.click(); buttonElement.value!.click(); window.document.body.innerHTML.should.containEql("Clicked 3 times"); }); /** * When a consuming application uses the DOM APIs to add children to an * element, those children should be left in place after rendering if they're * not specified as part of the JSX. */ it("preserves children inserted with DOM APIs that are prepended to the front of the children list", async () => { const { Component, state } = componentFactory(); await run((env) => ( )); should.equal(state.subrootElement.value!.children.length, 1); should.equal( state.rootElement.value!.querySelectorAll( '[data-forgo="child-of-root"]' ).length, 1, "Root element should have the appended element" ); state.buttonElement.value!.click(); should.equal(state.subrootElement.value!.children.length, 2); should.equal( state.rootElement.value!.querySelectorAll( '[data-forgo="child-of-root"]' ).length, 2, "Root element should have both appended elements" ); }); it("preserves children inserted with DOM APIs that are appended after of managed children", async () => { const { Component, state } = componentFactory(); await run((env) => ( )); should.equal( state.rootElement.value!.querySelectorAll( '[data-forgo="child-of-root"]' ).length, 1, "Root element should have the appended element" ); state.buttonElement.value!.click(); should.equal( state.rootElement.value!.querySelectorAll( '[data-forgo="child-of-root"]' ).length, 2, "Root element should have both appended elements" ); }); it("preserves children inserted with DOM APIs that inserted in the middle of managed children", async () => { const { Component, state } = componentFactory(); await run((env) => ); should.equal( state.rootElement.value!.querySelectorAll( '[data-forgo="child-of-root"]' ).length, 1, "Root element should have the appended element" ); state.buttonElement.value!.click(); should.equal( state.rootElement.value!.querySelectorAll( '[data-forgo="child-of-root"]' ).length, 2, "Root element should have both appended elements" ); }); it("doesn't add attributes to unmanaged elements", async () => { const { Component, state } = componentFactory(); await run((env) => ); const el = state.rootElement.value!.querySelector( '[data-forgo="child-of-root"]' )!; // Mutate the component to force it to interact with the unmanaged element // we added after the first render state.buttonElement.value!.click(); should.equal(Array.from(el.attributes).length, 1); const [{ name: attrName, value: attrValue }] = Array.from(el.attributes); should.deepEqual([attrName, attrValue], ["data-forgo", "child-of-root"]); }); /** * If we mess up the algorithm for ignoring unmanaged nodes, what happens is * forgo identifies an unmanaged node as a replacement candidate for a * managed node and syncs its attrs/children onto the unmanaged node. So * here, we're basically checking that all elements mangaged by forgo still * have the tag they're supposed to have. This works because our test * component makes the unmanaged nodes a different tag name than any of the * managed nodes. */ it("doesn't convert an unmanaged element into a managed element", async () => { const { Component, state } = componentFactory(); await run((env) => ); // Mutate the component to force it to interact with the unmanaged element // we added after the first render state.buttonElement.value!.click(); [state.buttonElement, state.pElement, state.subrootElement].forEach( (el) => should.notEqual(el.value!.tagName, unmanagedNodeTagName) ); // Same test as above, but pull the elements out of the live DOM. This // covers if there's any case where Forgo mis-transforms the elements // without updating the ref. [ state.rootElement.value!.querySelector("#button"), state.rootElement.value!.querySelector("#p"), state.rootElement.value!.querySelector("#subRoot"), ].forEach((el) => { should.exist(el); should.notEqual(el!.tagName, unmanagedNodeTagName); }); }); it("leaves managed nodes alone when an unmanaged node is removed from the DOM", async () => { const { Component, state } = componentFactory(); await run((env) => ); // Sanity check that our tests are correctly identifying unmanaged nodes // before testing their removal should.notEqual( state.rootElement.value!.querySelectorAll("[data-forgo]").length, 0 ); // Mutate the component to force it to interact with the unmanaged element // we added after the first render state.buttonElement.value!.click(); state.remove(); // All unmanaged nodes are gone should.equal( state.rootElement.value!.querySelectorAll("[data-forgo]").length, 0 ); // All managed nodes still exist [ state.rootElement.value!.querySelector("#button"), state.rootElement.value!.querySelector("#p"), state.rootElement.value!.querySelector("#subRoot"), ].forEach((el) => { should.exist(el); should.notEqual(el!.tagName, unmanagedNodeTagName); }); }); }); } ================================================ FILE: src/test/rerenderChild/index.ts ================================================ import { JSDOM } from "jsdom"; import htmlFile from "../htmlFile.js"; import { parentCounter, renderAgain, run, runSharedNode } from "./script.js"; import should from "should"; export default function () { describe("rerenders child", () => { it("rerenders child on child node", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; run(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); window.document.body.innerHTML.should.containEql("Parent counter is 1"); window.document.body.innerHTML.should.containEql("Child counter is 1"); renderAgain(); window.document.body.innerHTML.should.containEql("Parent counter is 1"); window.document.body.innerHTML.should.containEql("Child counter is 2"); }); it("rerenders child sharing parent node", async () => { const dom = new JSDOM(htmlFile(), { runScripts: "outside-only", resources: "usable", }); const window = dom.window; runSharedNode(dom); await new Promise((resolve) => { window.addEventListener("load", () => { resolve(); }); }); should.equal(parentCounter, 1); window.document.body.innerHTML.should.containEql("Child counter is 1"); renderAgain(); should.equal(parentCounter, 1); window.document.body.innerHTML.should.containEql("Child counter is 2"); }); }); } ================================================ FILE: src/test/rerenderChild/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; export let parentCounter = 0; export let childCounter = 0; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } const Parent = () => { parentCounter = 0; return new forgo.Component({ render() { parentCounter++; return (

Parent counter is {parentCounter}

); }, }); }; const ParentWithSharedNode = () => { parentCounter = 0; return new forgo.Component({ render() { parentCounter++; return ; }, }); }; const Child = () => { childCounter = 0; component = new forgo.Component({ render() { childCounter++; return (

Child counter is {childCounter}

); }, }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } export function runSharedNode(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, document.getElementById("root")); }); } ================================================ FILE: src/test/rerenderMayChangeRootNode.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state: { component: forgo.Component<{}> | null; } = { component: null }; const Parent1 = () => { return new forgo.Component({ render() { return ; }, }); }; const Parent2 = () => { return new forgo.Component({ render() { return ; }, }); }; const Child = () => { let counter = 0; state.component = new forgo.Component({ render() { counter++; return counter === 1 ? ( <>
This is a child node.
This is a child node.
) : ( <>
This is a child node.
); }, }); return state.component; }; return { Component: Parent1, state, }; } export default function () { it("rerender may change root node", async () => { const { Component, state } = componentFactory(); const { document } = await run(() => ); const node1FirstPass = document.getElementById("node1"); const node2FirstPass = document.getElementById("node2"); const stateFirstPass = forgo.getForgoState( node2FirstPass as ChildNode ) as forgo.NodeAttachedState; should.equal( stateFirstPass.components[0].component.__internal.element.node, node1FirstPass ); should.equal( stateFirstPass.components[1].component.__internal.element.node, node1FirstPass ); should.equal( stateFirstPass.components[2].component.__internal.element.node, node1FirstPass ); state.component!.update(); const node2SecondPass = document.getElementById("node2"); const stateSecondPass = forgo.getForgoState( node2SecondPass as ChildNode ) as forgo.NodeAttachedState; should.equal( stateSecondPass.components[0].component.__internal.element.node, node2SecondPass ); should.equal( stateSecondPass.components[1].component.__internal.element.node, node2SecondPass ); should.equal( stateSecondPass.components[2].component.__internal.element.node, node2SecondPass ); }); } ================================================ FILE: src/test/rerenderMayChangeRootNodeOnParents.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state: { component: forgo.Component<{}> | null; } = { component: null }; const Parent = () => { state.component = new forgo.Component({ render() { return ; }, }); return state.component; }; const Child = () => { let counter = 0; return new forgo.Component({ render() { counter++; return counter === 1 ? (
This is a child node.
) : (

This is a child node.

); }, }); }; return { Component: Parent, state, }; } export default function () { it("rerender may change root node on parents", async () => { const { Component, state } = componentFactory(); await run(() => ); const oldId: string = (state.component!.__internal.element.node as Element) .id; should.equal(oldId, "node1"); state.component!.update(); const newId: string = (state.component!.__internal.element.node as Element) .id; should.equal(newId, "node2"); }); } ================================================ FILE: src/test/rerenderMayUnmountParents.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state: { component: forgo.Component<{}> | null; parent1Unmounted: boolean; parent2Unmounted: boolean; childUnmounted: boolean; } = { component: null, parent1Unmounted: false, parent2Unmounted: false, childUnmounted: false, }; const Parent1 = () => { const component = new forgo.Component({ render() { return ; }, }); component.unmount(() => { state.parent1Unmounted = true; }); return component; }; const Parent2 = () => { const component = new forgo.Component({ render() { return ; }, }); component.unmount(() => { state.parent2Unmounted = true; }); return component; }; const Child = () => { let counter = 0; state.component = new forgo.Component({ render() { counter++; return counter === 1 ?
This is a child node.
: <>; }, }); state.component.unmount(() => { state.childUnmounted = true; }); return state.component; }; return { Component: Parent1, state, }; } export default function () { it("rerender may unmount parents", async () => { const { Component, state } = componentFactory(); await run(() => ); state.component!.update(); should.equal(state.parent1Unmounted, true); should.equal(state.parent2Unmounted, true); should.equal(state.childUnmounted, true); }); } ================================================ FILE: src/test/rootElementChangeDoesNotUnmount.tsx ================================================ import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state = { unmountCount: 0, renderCount: 0, component: null as forgo.Component<{}> | null, }; const TestComponent = () => { const Child = () => { const component = new forgo.Component({ render() { state.renderCount++; if (state.renderCount % 2 === 0) { return
This is a div
; } else { return

But this is a paragraph

; } }, }); component.unmount(() => { state.unmountCount++; }); return component; }; const component = new forgo.Component({ render() { return (
); }, }); state.component = component; return component; }; return { TestComponent, state, }; } export default function () { describe("root element changes", () => { it("does not unmount", async () => { const { TestComponent, state } = componentFactory(); await run(() => ); state.component!.update(); state.component!.update(); state.component!.update(); state.component!.update(); state.component!.update(); state.renderCount.should.equal(6); state.unmountCount.should.equal(0); }); }); } ================================================ FILE: src/test/shouldUpdate.tsx ================================================ import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state: { component: forgo.Component<{}> | null; } = { component: null }; const TestComponent = () => { let counter = 0; state.component = new forgo.Component({ render() { counter++; return
Counter is {counter}
; }, }); state.component.shouldUpdate(() => { return false; }); return state.component; }; return { Component: TestComponent, state, }; } export default function () { it("skips render if shouldUpdate() returns false", async () => { const { Component, state } = componentFactory(); const { document } = await run(() => ); state.component!.update(); state.component!.update(); state.component!.update(); document.body.innerHTML.should.containEql("Counter is 1"); }); } ================================================ FILE: src/test/ssr-simple.tsx ================================================ import { JSDOM } from "jsdom"; import * as forgo from "../index.js"; import htmlFile from "./htmlFile.js"; import { run } from "./componentRunner.js"; function componentFactory() { const state: {} = {}; interface GreetProps { text: string; } const Greet = (_props: GreetProps) => { return new forgo.Component({ render(props) { return

{props.text}

; }, }); }; const Parent = () => { return new forgo.Component({ render() { return (
); }, }); }; return { Component: Parent, state, }; } export default function () { it("simple server side rendering", async () => { const dom = new JSDOM(htmlFile("

Hello1

World1

"), { runScripts: "outside-only", resources: "usable", }); const { Component } = componentFactory(); const { document } = await run(() => , dom); document.body.innerHTML.should.not.containEql("World1"); document.body.innerHTML.should.containEql("World2"); }); } ================================================ FILE: src/test/test.ts ================================================ import sourceMapSupport from "source-map-support"; sourceMapSupport.install(); import mount from "./mount/index.js"; import boundary from "./boundary/index.js"; import passProps from "./passProps/index.js"; import elementRef from "./elementRef/index.js"; import rerender from "./rerender.js"; import hydrate from "./hydrate/index.js"; import componentUnmount from "./componentUnmount.js"; import componentMount from "./componentMount.js"; import nodeState from "./nodeState/index.js"; import replaceByKey from "./replaceByKey/index.js"; import clearsOldProps from "./clearsOldProps/index.js"; import shouldUpdate from "./shouldUpdate.js"; import renderPrimitives from "./renderPrimitives/index.js"; import assertIsComponent from "./assertIsComponent/index.js"; import rendersArraysInChildren from "./rendersArraysInChildren/index.js"; import rerenderChild from "./rerenderChild/index.js"; import afterRender from "./afterRender/index.js"; import propsChanges from "./propsChanges/index.js"; import componentFragment from "./componentFragment/index.js"; import mountRunsOnceWhenRenderingFragment from "./mountRunsOnceWhenRenderingFragment/index.js"; import mountRunsOnceWhenChildRendersFragment from "./mountRunsOnceWhenChildRendersFragment/index.js"; import fragmentUnmountRunsOnce from "./fragmentUnmountRunsOnce/index.js"; import replacingFragmentWithNodeWontUnmount from "./replacingFragmentWithNodeWontUnmount/index.js"; import childWithFragmentUnmounts from "./childWithFragmentUnmounts/index.js"; import rerenderMayUnmountParents from "./rerenderMayUnmountParents.js"; import rerenderMayChangeRootNode from "./rerenderMayChangeRootNode.js"; import dangerouslySetInnerHTML from "./dangerouslySetInnerHTML/index.js"; import css from "./css/index.js"; import inheritedCustomElement from "./inheritedCustomElement/index.js"; import fragmentOverwriteDoesNotUnmount from "./fragmentOverwriteDoesNotUnmount/index.js"; import ssrSimple from "./ssr-simple.js"; import componentKeepsStateWhenReordered from "./componentKeepsStateWhenReordered/index.js"; import elementKeepsStateWhenReordered from "./elementKeepsStateWhenReordered/index.js"; import unmountsParentWhenNodeIsNull from "./unmountsParentWhenNodeIsNull.js"; import rerenderMayChangeRootNodeOnParents from "./rerenderMayChangeRootNodeOnParents.js"; import keyedFragmentsPreserveChildStates from "./keyedFragmentsPreserveChildStates/index.js"; import rootElementChangeDoesNotUnmount from "./rootElementChangeDoesNotUnmount.js"; import fragmentMountTiming from "./fragmentMountEvent.js"; import componentApi from "./componentApi.js"; mount(); boundary(); passProps(); elementRef(); rerender(); hydrate(); componentMount(); componentUnmount(); nodeState(); replaceByKey(); clearsOldProps(); shouldUpdate(); renderPrimitives(); assertIsComponent(); rendersArraysInChildren(); rerenderChild(); afterRender(); propsChanges(); componentFragment(); mountRunsOnceWhenRenderingFragment(); mountRunsOnceWhenChildRendersFragment(); fragmentUnmountRunsOnce(); replacingFragmentWithNodeWontUnmount(); childWithFragmentUnmounts(); rerenderMayUnmountParents(); rerenderMayChangeRootNode(); rerenderMayChangeRootNodeOnParents(); dangerouslySetInnerHTML(); css(); inheritedCustomElement(); fragmentOverwriteDoesNotUnmount(); ssrSimple(); componentKeepsStateWhenReordered(); elementKeepsStateWhenReordered(); unmountsParentWhenNodeIsNull(); keyedFragmentsPreserveChildStates(); rootElementChangeDoesNotUnmount(); fragmentMountTiming(); componentApi(); ================================================ FILE: src/test/unmountsNonTopLevelParentWhenNodeIsNull/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let isFirstRender = true; export let hasUnmounted = false; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } const Child = () => { component = new forgo.Component({ render() { if (isFirstRender) { isFirstRender = false; return
Hello, world
; } else { return null; } }, }); component.unmount(() => { hasUnmounted = true; }); return component; }; const Parent = () => { return new forgo.Component({ render() { return (
); }, }); }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/unmountsParentWhenNodeIsNull/script.tsx ================================================ import * as forgo from "../../index.js"; import { DOMWindow, JSDOM } from "jsdom"; import { mount, setCustomEnv } from "../../index.js"; let window: DOMWindow; let document: Document; let isFirstRender = true; export let hasUnmounted = false; let component: forgo.Component<{}>; export function renderAgain() { component.update(); } const Parent = () => { component = new forgo.Component({ render() { if (isFirstRender) { isFirstRender = false; return
Hello, world
; } else { return null; } }, }); component.unmount(() => { hasUnmounted = true; }); return component; }; export function run(dom: JSDOM) { window = dom.window; document = window.document; setCustomEnv({ window, document }); window.addEventListener("load", () => { mount(, window.document.getElementById("root")); }); } ================================================ FILE: src/test/unmountsParentWhenNodeIsNull.tsx ================================================ import should from "should"; import * as forgo from "../index.js"; import { run } from "./componentRunner.js"; const componentFactory = () => { const state: { shouldRenderNull: boolean; internalState: string | null; renderedElement?: ChildNode; update: () => void; hasMounted: boolean; hasUnmounted: boolean; } = { shouldRenderNull: true, internalState: null, hasMounted: false, hasUnmounted: false, update: () => undefined, }; const Component = () => { // We want to reassign this inside the component ctor closure to test that // the component doesn't get recreated when it stops rendering null state.internalState = Math.random().toString(); const component = new forgo.Component({ render() { if (state.shouldRenderNull) { return null; } else { return
Internal state is {state.internalState}
; } }, }); state.update = component.update.bind(component); component.mount(() => { state.hasMounted = true; component.unmount(() => (state.hasUnmounted = true)); }); component.afterRender((_props, _previousNode, component) => { state.renderedElement = component.__internal.element.node!; }); return component; }; return { state, Component }; }; export default function () { it("does not unmount parent when render returns null", async () => { const { Component, state } = componentFactory(); await run(() => ); // Make sure that the first render mounts the component, even if it renders // null should.equal(state.hasMounted, true); should.equal(state.hasUnmounted, false); should.equal(state.renderedElement!.nodeType, 8); const internalState = state.internalState; // Sanity checks for rendering the randomized state should.equal(typeof internalState, "string"); should.notEqual(internalState, ""); state.shouldRenderNull = true; state.update(); should.equal(state.hasUnmounted, false); should.equal(state.renderedElement!.nodeType, 8); state.shouldRenderNull = false; state.update(); should.equal(state.hasUnmounted, false); should.equal(state.renderedElement!.nodeType, 1); const newInternalState = state.internalState; should.equal(newInternalState, internalState); }); } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "outDir": "./dist", "allowJs": false, "target": "ES2015", "module": "ESNext", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "lib": ["ES2020", "DOM"], "sourceMap": true, "strict": true, "jsx": "react", "jsxFactory": "forgo.createElement", "jsxFragmentFactory": "forgo.Fragment", "declaration": true, // esbuild recommends enabling this to help enforce throwing errors in // scenarios where esbuild and tsc's behavior would differ // // https://esbuild.github.io/content-types/#isolated-modules "isolatedModules": true }, "include": ["./src/**/*"] }