Showing preview only (256K chars total). Download the full file or copy to clipboard to get everything.
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": ["<node_internals>/**"],
"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": ["<node_internals>/**"],
"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 <p>Hello, world!</p>;
}
});
};
```
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 (
<div>
<p>Hello, {firstName}!</p>
<button type="button" onclick={onclick}>
The button has been clicked {clickCounter} times in {seconds} seconds
</button>
</div>
);
}
});
// 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 <p>Hello, {name}!</p>;
}
});
};
```
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<T extends string | number> = {
data: T[];
};
const List = <T extends string | number>(initial: ListProps<T>) =>
new forgo.Component<ListProps<T>>({
render(props) {
return (
<ul>
{props.data.map((item) => (
<li>{item}</li>
))}
</ul>
);
},
});
const App = () =>
new forgo.Component({
render(props) {
return <List data={[1, "2", 3]} />;
},
});
```
_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<HelloWorldProps>({
render({ name }) {
return <p>Hello, {name}!</p>;
}
});
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(<App />, 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(<App />, "#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 (
<div>
<Greeter firstName="Jeswin" />
<Greeter firstName="Kai" />
</div>
);
}
});
};
const Greeter = (_initialProps) => {
return new forgo.Component({
render(props, _component) {
return <div>Hello {props.firstName}</div>;
}
});
};
```
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 <NumberComponent myNumber={2} />;
}
});
};
```
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
<Child>
<p>Hello, world!</p>
</Child>
)
}
});
}
const Child = () => {
return new forgo.Component({
render(props) {
return (
<div>
<p>Here's what the parent told us to render:</p>
{props.children}
</div>
)
}
});
}
```
## 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 (
<div>
<input type="text" ref={myInputRef} />
<button type="button" onclick={onClick}>Click me!</button>
</div>
);
}
});
};
```
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 (
<div>
<input type="text" oninput={onInput} />
</div>
);
}
});
};
```
## 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 (
<div>
{people.map((item) => (
<Child key={item.id} firstName={item.firstName} />
))}
</div>
);
}
});
};
const Child = (initialProps) => {
return new forgo.Component({
render(props) {
return <div>Hello {props.firstName}</div>;
},
});
};
```
## 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 <p>Loading data...</p>;
}
// After messages are fetched, the component will rerender and now we can
// show the data.
return (
<div>
<header>Your Inbox</header>
<ul>
{messages.map((message) => (
<li>{message}</li>
))}
</ul>
</div>
);
}
});
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 <div id="hello">Hello {props.firstName}</div>;
}
});
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 <div>Hello {props.firstName}</div>;
}
});
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 <div>Hello {props.firstName}</div>;
}
});
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 (
<div>
<BadComponent />
</div>
);
},
error(props, error, _component) {
return (
<p>
Error in {props.name}: {error.message}
</p>
);
}
});
}
```
## 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 <div id="hello">Hello {props.firstName}</div>;
}
});
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 (
<button type="button" onclick={addTodos}>
Add a Todo
</button>
);
}
});
}
```
`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(<Component />);
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 (
<Router>
<Link href="/">Go to Home Page</Link>
{matchExactUrl("/", () => <Home />) ||
matchUrl("/customers", () => <Customers />) ||
matchUrl("/about", () => <AboutPage />)}
</Router>
);
}
});
}
```
## 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 (
<div>
{mailboxState.messages.map((m) => <p>{m}</p>)}
</div>
);
}
return (
<div>
<p>There are no messages for {mailboxState.username}.</p>
</div>
);
}
});
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 (
<Suspense fallback={() => "Loading..."}>
<LazyComponent title="It's that easy :D" />
</Suspense>
);
}
});
}
```
## 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 <p id="live-scores">Top score is {props.topscore}</p>;
}
});
}
// Mount it on a DOM node usual
window.addEventListener("load", () => {
mount(<SimpleTimer />, 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 <div>Hello world</div>;
}
});
}
// Get the html (string) and serve it via koa, express etc.
const html = render(<MyComponent />);
```
## 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 (
<div>
<p>
This component continually rerenders. Forgo manages the timestamp,
but delegates control of the chart to ApexCharts.
</p>
<div ref={chartElement}></div>
<p>
The current time is:{" "}
<time datetime={now.toISOString()}>{now.toLocaleString()}</time>
</p>
</div>
);
}
});
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<jeswinpk@agilehead.com>",
"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<T> = {
value?: T;
};
/*
We have two types of elements:
1. DOM Elements
2. Component Elements
*/
export type ForgoElementBaseProps = {
children?: ForgoNode | ForgoNode[];
ref?: ForgoRef<Element>;
};
// 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<TProps extends object> = (
props: TProps & ForgoElementBaseProps
) => ForgoLegacyComponent<TProps>;
export type ForgoNewComponentCtor<TProps extends object> = (
props: TProps & ForgoElementBaseProps
) => Component<TProps>;
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 <div>Hello</div>
If the ForgoElement represents a Component, then the type points to a ForgoComponentCtor.
eg: The type will be MyComponent for <MyComponent />
*/
export type ForgoElementBase<TProps extends ForgoElementBaseProps> = {
key?: any;
props: TProps;
__is_forgo_element__: true;
};
export type ForgoDOMElement<TProps extends ForgoDOMElementProps> =
ForgoElementBase<TProps> & {
type: string;
};
export type ForgoComponentElement<TProps extends ForgoElementBaseProps> =
ForgoElementBase<TProps> & {
type: ForgoNewComponentCtor<TProps>;
};
export type ForgoFragment = {
type: typeof Fragment;
props: { children?: ForgoNode | ForgoNode[] };
__is_forgo_element__: true;
};
export type ForgoElement<TProps extends ForgoElementBaseProps> =
| ForgoDOMElement<TProps>
| ForgoComponentElement<TProps>;
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<any> | ForgoFragment;
/*
Forgo stores Component state on the element on which it is mounted.
Say Custom1 renders Custom2 which renders Custom3 which renders <div>Hello</div>.
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<TProps extends ForgoElementBaseProps> = {
key?: any;
ctor: ForgoNewComponentCtor<TProps> | ForgoSimpleComponentCtor<TProps>;
component: Component<TProps>;
props: TProps;
nodes: ChildNode[];
isMounted: boolean;
};
/*
This is the state data structure which gets stored on a node.
See explanation for NodeAttachedComponentState<TProps>
*/
export type NodeAttachedState = {
key?: string | number;
props?: { [key: string]: any };
components: NodeAttachedComponentState<any>[];
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.<whatever>, 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<TProps extends object> {
render: (
props: TProps & ForgoElementBaseProps,
component: Component<TProps>
) => ForgoNode | ForgoNode[];
error?: (
props: TProps & ForgoElementBaseProps,
error: unknown,
component: Component<TProps>
) => 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<Function>;
};
/**
* 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<TProps extends object>
extends ComponentEventListenerBase {
mount: Array<
(
props: TProps & ForgoElementBaseProps,
component: Component<TProps>
) => void
>;
unmount: Array<
(
props: TProps & ForgoElementBaseProps,
component: Component<TProps>
) => void
>;
afterRender: Array<
(
props: TProps & ForgoElementBaseProps,
previousNode: ChildNode | undefined,
component: Component<TProps>
) => void
>;
shouldUpdate: Array<
(
newProps: TProps & ForgoElementBaseProps,
oldProps: TProps & ForgoElementBaseProps,
component: Component<TProps>
) => boolean
>;
}
interface ComponentInternal<TProps extends object> {
unmounted: boolean;
registeredMethods: ForgoComponentMethods<TProps>;
eventListeners: ComponentEventListeners<TProps>;
element: ForgoElementArg;
}
const lifecycleEmitters = {
mount<TProps extends object>(
component: Component<TProps>,
props: TProps
): void {
component.__internal.eventListeners.mount.forEach((cb) =>
cb(props, component)
);
},
unmount<TProps extends object>(component: Component<TProps>, props: TProps) {
component.__internal.eventListeners.unmount.forEach((cb) =>
cb(props, component)
);
},
shouldUpdate<TProps extends object>(
component: Component<TProps>,
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<TProps extends object>(
component: Component<TProps>,
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<TProps extends object> {
/** @internal */
public __internal: ComponentInternal<TProps>;
/**
* @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<TProps>) {
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<TProps>["mount"][number]) {
this.__internal.eventListeners["mount"].push(listener as any);
}
public unmount(listener: ComponentEventListeners<TProps>["unmount"][number]) {
this.__internal.eventListeners["unmount"].push(listener as any);
}
public shouldUpdate(
listener: ComponentEventListeners<TProps>["shouldUpdate"][number]
) {
this.__internal.eventListeners["shouldUpdate"].push(listener as any);
}
public afterRender(
listener: ComponentEventListeners<TProps>["afterRender"][number]
) {
this.__internal.eventListeners["afterRender"].push(listener as any);
}
}
/**
* jsxFactory function
*/
export function createElement<
TProps extends ForgoDOMElementProps & { key?: any }
>(
type:
| string
| ForgoNewComponentCtor<TProps>
| ForgoSimpleComponentCtor<TProps>,
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<any>[],
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<any>[]
): 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<any>[] | 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 <div>Hello world</div>
}
}
}
*/
function renderDOMElement<TProps extends ForgoDOMElementProps>(
forgoElement: ForgoDOMElement<TProps>,
insertionOptions: NodeInsertionOptions,
statesAwaitingAttach: NodeAttachedComponentState<any>[],
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<ChildNode>,
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 <MySideBar size="large" />
*/
function renderComponent<TProps extends ForgoDOMElementProps>(
forgoComponent: ForgoComponentElement<TProps>,
insertionOptions: NodeInsertionOptions,
statesAwaitingAttach: NodeAttachedComponentState<any>[],
mountOnPreExistingDOM: boolean
// boundary: ForgoComponent<any> | undefined
): RenderResult {
function renderExistingComponent(
insertAt: number,
childNodes: NodeListOf<ChildNode>,
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<any> = {
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<any>[],
boundary: Component<any> | 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<TProps extends object>(
forgoNode: ForgoNode,
insertionOptions: SearchableNodeInsertionOptions,
statesToAttach: NodeAttachedComponentState<any>[],
componentState: NodeAttachedComponentState<TProps>,
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<any>[],
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<ChildNode>,
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<any>[]
) {
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<any>[],
oldStates: NodeAttachedComponentState<any>[]
): 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<any>[],
oldComponentStates: NodeAttachedComponentState<any>[] | 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<any>[],
oldComponentStates: NodeAttachedComponentState<any>[] | 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<TProps>,
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<TProps>,
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<TProps>,
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<any>[]
) {
// 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<any> {
return (
forgoNode !== undefined &&
forgoNode !== null &&
(forgoNode as any).__is_forgo_element__ === true
);
}
function isForgoDOMElement(node: ForgoNode): node is ForgoDOMElement<any> {
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<TProps extends object> = {
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 = <TProps extends object>(
legacyComponent: ForgoLegacyComponent<TProps>
): Component<TProps> => {
const mkRenderArgs = (component: Component<TProps>): ForgoRenderArgs => ({
get element() {
return component.__internal.element;
},
update(props) {
return component.update(props as unknown as TProps);
},
});
const componentBody: ForgoComponentMethods<TProps> = {
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<TProps>({
...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<TProps extends object>(
ctor: ForgoNewComponentCtor<TProps> | ForgoSimpleComponentCtor<TProps>,
component: Component<TProps> | ForgoLegacyComponent<TProps>
): Component<TProps> {
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<T>(
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<ChildNode>,
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<ChildNode>,
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<Props, Defaults> =
// Distribute over unions
Props extends any // Make any properties included in Default optional
? Partial<Pick<Props, Extract<keyof Props, keyof Defaults>>> &
// Include the remaining properties from Props
Pick<Props, Exclude<keyof Props, keyof Defaults>>
: never;
export type LibraryManagedAttributes<Component, Props> = Component extends {
defaultProps: infer Defaults;
}
? Defaultize<Props, Defaults>
: 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<Target extends EventTarget = SVGElement>
extends HTMLAttributes<Target> {
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<TypedEvent, "currentTarget"> & {
readonly currentTarget: Target;
};
export type TargetedAnimationEvent<Target extends EventTarget> = TargetedEvent<
Target,
AnimationEvent
>;
export type TargetedClipboardEvent<Target extends EventTarget> = TargetedEvent<
Target,
ClipboardEvent
>;
export type TargetedCompositionEvent<Target extends EventTarget> = TargetedEvent<
Target,
CompositionEvent
>;
export type TargetedDragEvent<Target extends EventTarget> = TargetedEvent<
Target,
DragEvent
>;
export type TargetedFocusEvent<Target extends EventTarget> = TargetedEvent<
Target,
FocusEvent
>;
export type TargetedKeyboardEvent<Target extends EventTarget> = TargetedEvent<
Target,
KeyboardEvent
>;
export type TargetedMouseEvent<Target extends EventTarget> = TargetedEvent<
Target,
MouseEvent
>;
export type TargetedPointerEvent<Target extends EventTarget> = TargetedEvent<
Target,
PointerEvent
>;
export type TargetedTouchEvent<Target extends EventTarget> = TargetedEvent<
Target,
TouchEvent
>;
export type TargetedTransitionEvent<Target extends EventTarget> = TargetedEvent<
Target,
TransitionEvent
>;
export type TargetedUIEvent<Target extends EventTarget> = TargetedEvent<
Target,
UIEvent
>;
export type TargetedWheelEvent<Target extends EventTarget> = TargetedEvent<
Target,
WheelEvent
>;
export type TargetedInputEvent<Target extends EventTarget> = TargetedEvent<
Target,
InputEvent
>;
export type TargetedSecurityPolicyViolationEvent<Target extends EventTarget> = TargetedEvent<
Target,
SecurityPolicyViolationEvent
>;
export interface EventHandler<E extends TargetedEvent> {
/**
* 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<Target extends EventTarget> = EventHandler<
TargetedAnimationEvent<Target>
>;
export type ClipboardEventHandler<Target extends EventTarget> = EventHandler<
TargetedClipboardEvent<Target>
>;
export type CompositionEventHandler<Target extends EventTarget> = EventHandler<
TargetedCompositionEvent<Target>
>;
export type DragEventHandler<Target extends EventTarget> = EventHandler<
TargetedDragEvent<Target>
>;
export type FocusEventHandler<Target extends EventTarget> = EventHandler<
TargetedFocusEvent<Target>
>;
export type GenericEventHandler<Target extends EventTarget> = EventHandler<
TargetedEvent<Target>
>;
export type KeyboardEventHandler<Target extends EventTarget> = EventHandler<
TargetedKeyboardEvent<Target>
>;
export type MouseEventHandler<Target extends EventTarget> = EventHandler<
TargetedMouseEvent<Target>
>;
export type PointerEventHandler<Target extends EventTarget> = EventHandler<
TargetedPointerEvent<Target>
>;
export type TouchEventHandler<Target extends EventTarget> = EventHandler<
TargetedTouchEvent<Target>
>;
export type TransitionEventHandler<Target extends EventTarget> = EventHandler<
TargetedTransitionEvent<Target>
>;
export type UIEventHandler<Target extends EventTarget> = EventHandler<
TargetedUIEvent<Target>
>;
export type WheelEventHandler<Target extends EventTarget> = EventHandler<
TargetedWheelEvent<Target>
>;
export type InputEventHandler<Target extends EventTarget> = EventHandler<
TargetedInputEvent<Target>
>;
export type SecurityPolicyViolationEventHandler<Target extends EventTarget> = EventHandler<
TargetedSecurityPolicyViolationEvent<Target>
>;
/*
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<Target extends EventTarget>
extends ForgoDOMElementProps {
// Image Events
onload?: GenericEventHandler<Target>;
onerror?: GenericEventHandler<Target>;
// Clipboard Events
oncopy?: ClipboardEventHandler<Target>;
oncut?: ClipboardEventHandler<Target>;
onpaste?: ClipboardEventHandler<Target>;
// Composition Events
oncompositionend?: CompositionEventHandler<Target>;
oncompositionstart?: CompositionEventHandler<Target>;
oncompositionupdate?: CompositionEventHandler<Target>;
// Toggle Events
onbeforetoggle?: GenericEventHandler<Target>;
ontoggle?: GenericEventHandler<Target>;
// Focus Events
onfocus?: FocusEventHandler<Target>;
onfocusin?: FocusEventHandler<Target>;
onfocusout?: FocusEventHandler<Target>;
onblur?: FocusEventHandler<Target>;
// Input Events
onbeforeinput?: InputEventHandler<Target>;
oninput?: InputEventHandler<Target>;
// Form Events
oncancel?: GenericEventHandler<Target>;
onchange?: GenericEventHandler<Target>;
onsearch?: GenericEventHandler<Target>;
onsubmit?: GenericEventHandler<Target>;
oninvalid?: GenericEventHandler<Target>;
onreset?: GenericEventHandler<Target>;
onformdata?: GenericEventHandler<Target>;
// Keyboard Events
onkeydown?: KeyboardEventHandler<Target>;
/**
* @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<Target>;
onkeyup?: KeyboardEventHandler<Target>;
// Media Events
onabort?: GenericEventHandler<Target>;
oncanplay?: GenericEventHandler<Target>;
oncanplaythrough?: GenericEventHandler<Target>;
ondurationchange?: GenericEventHandler<Target>;
onemptied?: GenericEventHandler<Target>;
onencrypted?: GenericEventHandler<Target>;
onended?: GenericEventHandler<Target>;
onloadeddata?: GenericEventHandler<Target>;
onloadedmetadata?: GenericEventHandler<Target>;
onloadstart?: GenericEventHandler<Target>;
onpause?: GenericEventHandler<Target>;
onplay?: GenericEventHandler<Target>;
onplaying?: GenericEventHandler<Target>;
onprogress?: GenericEventHandler<Target>;
onratechange?: GenericEventHandler<Target>;
onseeked?: GenericEventHandler<Target>;
onseeking?: GenericEventHandler<Target>;
onstalled?: GenericEventHandler<Target>;
onsuspend?: GenericEventHandler<Target>;
ontimeupdate?: GenericEventHandler<Target>;
onvolumechange?: GenericEventHandler<Target>;
onwaiting?: GenericEventHandler<Target>;
// Drag Events
ondrag?: DragEventHandler<Target>;
ondragend?: DragEventHandler<Target>;
ondragenter?: DragEventHandler<Target>;
ondragleave?: DragEventHandler<Target>;
ondragover?: DragEventHandler<Target>;
ondragstart?: DragEventHandler<Target>;
ondrop?: DragEventHandler<Target>;
// Mouse Events
ondblclick?: MouseEventHandler<Target>;
onmousedown?: MouseEventHandler<Target>;
onmouseenter?: MouseEventHandler<Target>;
onmouseleave?: MouseEventHandler<Target>;
onmousemove?: MouseEventHandler<Target>;
onmouseout?: MouseEventHandler<Target>;
onmouseover?: MouseEventHandler<Target>;
onmouseup?: MouseEventHandler<Target>;
// Selection Events
onselect?: GenericEventHandler<Target>;
// Touch Events
ontouchcancel?: TouchEventHandler<Target>;
ontouchend?: TouchEventHandler<Target>;
ontouchmove?: TouchEventHandler<Target>;
ontouchstart?: TouchEventHandler<Target>;
// Pointer Events
onauxclick?: PointerEventHandler<Target>;
onclick?: PointerEventHandler<Target>;
oncontextmenu?: PointerEventHandler<Target>;
ongotpointercapture?: PointerEventHandler<Target>;
onlostpointercapture?: PointerEventHandler<Target>;
onpointerover?: PointerEventHandler<Target>;
onpointerenter?: PointerEventHandler<Target>;
onpointerdown?: PointerEventHandler<Target>;
onpointermove?: PointerEventHandler<Target>;
onpointerup?: PointerEventHandler<Target>;
onpointercancel?: PointerEventHandler<Target>;
onpointerout?: PointerEventHandler<Target>;
onpointerleave?: PointerEventHandler<Target>;
// Wheel Events
onwheel?: WheelEventHandler<Target>;
// Scroll Events
onscroll?: GenericEventHandler<Target>;
// Security Policy Violation Events
onsecuritypolicyviolation?: SecurityPolicyViolationEventHandler<Target>;
// Animation Events
onanimationcancel?: AnimationEventHandler<Target>;
onanimationend?: AnimationEventHandler<Target>;
onanimationiteration?: AnimationEventHandler<Target>;
onanimationstart?: AnimationEventHandler<Target>;
// Transition Events
ontransitioncancel?: TransitionEventHandler<Target>;
ontransitionend?: TransitionEventHandler<Target>;
ontransitionrun?: TransitionEventHandler<Target>;
ontransitionstart?: TransitionEventHandler<Target>;
}
export interface HTMLAttributes<RefType extends EventTarget = EventTarget>
// extends ForgoClassAttributes<RefType>,
extends DOMAttributes<RefType> {
[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<SVGElementTagNameMap, "a">]: 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<void>((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<void>((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<void>((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<void>((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 ? (
<div id="hello" prop="hello">
Hello world
</div>
) : (
<p id="hello" prop="world">
Hello world
</p>
);
},
});
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<HTMLDivElement> = {};
const component = new forgo.Component({
render() {
return <div ref={ref} />;
},
});
component.afterRender((_props, _previousNode, component) => {
currentNode = component.__internal.element.node;
});
return component;
};
const ComponentWithDangerouslySetInnerHTML = () => {
const component = new forgo.Component({
render() {
return <div dangerouslySetInnerHTML={{ __html: "<div></div>" }} />;
},
});
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(<TestComponent />, window.document.getElementById("root"));
});
}
export function runWithTextNode(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<ComponentOnTextNode />, window.document.getElementById("root"));
});
}
export function runWithRef(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<ComponentWithRef />, window.document.getElementById("root"));
});
}
export function runWithDangerouslySetInnerHtml(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(
<ComponentWithDangerouslySetInnerHTML />,
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<void>((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 <div>Hello world</div>;
}
export let componentError: any = undefined;
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
try {
mount(<BasicComponent />, 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<string>((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<ErrorBoundaryComponentProps>({
render({ children }) {
return <div>{children}</div>;
},
error(props, error) {
return (
<p>
Error in {props.name}: {(error as Error).message}
</p>
);
},
});
};
const App = () => {
return new forgo.Component({
render() {
return (
<div>
<ErrorBoundary name="ErrorComponent">
<ErrorComponent />
</ErrorBoundary>
</div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<App />, 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<void>((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 ? <Child /> : <p>1</p>;
},
});
return component;
};
const Child = () => {
const component = new forgo.Component({
render() {
return (
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
);
},
});
component.unmount(() => {
numUnmounts++;
});
return component;
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, 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<string>((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 (
<div id="mydiv" prop1="hello">
Hello world
</div>
);
} else {
return (
<div id="mydiv" prop2="world">
Hello world
</div>
);
}
},
});
return component;
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<BasicComponent />, 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<TestComponentProps> | null;
wrapper: forgo.Component<WrapperProps> | null;
render: {
key: string;
args: [TestComponentProps, forgo.Component<TestComponentProps>];
}[];
mount: {
key: string;
args: [TestComponentProps, forgo.Component<TestComponentProps>];
}[];
unmount: {
key: string;
args: [TestComponentProps, forgo.Component<TestComponentProps>];
}[];
afterRender: {
key: string;
args: [
TestComponentProps,
ChildNode | undefined,
forgo.Component<TestComponentProps>
];
}[];
shouldUpdate: {
key: string;
args: [
TestComponentProps,
TestComponentProps,
forgo.Component<TestComponentProps>
];
}[];
} = {
component: null,
wrapper: null,
render: [],
mount: [],
unmount: [],
afterRender: [],
shouldUpdate: [],
};
interface TestComponentProps {
foo: string;
forceUpdate?: boolean;
}
const TestComponent = (_props: TestComponentProps) => {
const component = new forgo.Component<TestComponentProps>({
render(...args) {
state.render.push({ key: "render", args });
return <p>Hello, world!</p>;
},
});
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<WrapperProps>({
render(props) {
if (props.unmount) return <p>Unmounted!</p>;
return <TestComponent {...props} />;
},
});
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(() => <Ctor />));
});
it("passes the right arguments to render()", async () => {
const { Component, state } = componentFactory();
await run((env) => <Component {...env} foo="foo" />);
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) => <Component {...env} foo="foo" />);
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) => <Component {...env} foo="foo" />);
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) => <Component {...env} foo="foo" />);
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) => <Component {...env} foo="foo" />);
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) => <Component {...env} foo="foo" />);
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<Props> = () => {
return {
mount(props) {
mounted = props;
},
unmount(props) {
unmounted = props;
},
render(props) {
rendered = props;
return <p>Hello, world!</p>;
},
afterRender(props) {
afterRender = props;
},
shouldUpdate(props) {
shouldUpdate = props;
return true;
},
};
};
interface ParentProps {
renderChild: boolean;
}
let component: forgo.Component<ParentProps>;
const ParentComponent = (initialProps: ParentProps) => {
component = new forgo.Component<ParentProps>({
render({ renderChild }) {
if (renderChild) return <LegacyComponent foo={1} />;
return null;
},
});
return component;
};
await run(() => <ParentComponent renderChild={true} />);
// 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<void>((resolve) => {
window.addEventListener("load", () => {
resolve();
});
});
const rootElem = window.document.getElementById("root") as HTMLElement;
rootElem.innerHTML.should.containEql(
"<div>1</div><div>2</div><div>3</div>"
);
});
it("nested fragment", async () => {
const dom = new JSDOM(htmlFile(), {
runScripts: "outside-only",
resources: "usable",
});
const window = dom.window;
runNested(dom);
await new Promise<void>((resolve) => {
window.addEventListener("load", () => {
resolve();
});
});
const rootElem = window.document.getElementById("root") as HTMLElement;
rootElem.innerHTML.should.containEql(
"<div>1</div><div>2</div><div>3</div><div>4</div>"
);
});
});
}
================================================
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 (
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
);
},
});
};
const NestedFragmentComponent = () => {
return new forgo.Component({
render() {
return (
<>
<>
<div>1</div>
<div>2</div>
</>
<>
<div>3</div>
</>
<div>4</div>
</>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, window.document.getElementById("root"));
});
}
export function runNested(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<NestedFragmentComponent />, 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<Map<unknown, string>>(
(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<unknown, string>();
interface StatefulComponentProps {
key: unknown;
}
const StatefulComponent = () => {
let state = getRandomString();
const component = new forgo.Component<StatefulComponentProps>({
render({ key }) {
componentStates.set(key, state);
return (
<p state={state} key={key}>
Component #{key}
</p>
);
},
});
component.unmount(({ key }) => {
componentStates.delete(key);
});
return component;
};
let sortOrder = 1;
let containerComponent: forgo.Component<StatefulComponentProps>;
export function reorderComponents() {
sortOrder = 2;
containerComponent.update({ key: undefined });
}
const ContainerComponent = () => {
containerComponent = new forgo.Component({
render() {
componentStates.clear();
return (
<div>
{sortOrder === 1 ? (
<>
<StatefulComponent key={0} />
<StatefulComponent key="1" />
<StatefulComponent key="2" />
<StatefulComponent key="3" />
<StatefulComponent key="4" />
<StatefulComponent key="5" />
</>
) : (
<>
<StatefulComponent key="1" />
<StatefulComponent key="4" />
<StatefulComponent key="3" />
<StatefulComponent key={0} />
<StatefulComponent key="2" />
<StatefulComponent key="5" />
</>
)}
</div>
);
},
});
return containerComponent;
};
export function run(dom: JSDOM) {
const window = dom.window;
const document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<ContainerComponent />, 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<HTMLDivElement>;
idAttr: string | null;
parentChildrenCount: number;
} = { parentEl: {}, parentChildrenCount: 0, idAttr: null };
const TestComponent = () => {
const Child = () => {
const component = new forgo.Component({
render() {
return <div>Hello world</div>;
},
});
component.mount(() => {
state.idAttr = state.parentEl.value!.getAttribute("id");
});
return component;
};
const component = new forgo.Component({
render() {
return (
<div ref={state.parentEl} id="hello">
<Child />
</div>
);
},
});
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 <div id="hello"></div>;
},
});
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(() => <TestComponent />);
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(() => <TestComponent />);
should.equal(state.idAttr, "hello");
});
it("renders all descendants before calling the parent's mount()", async () => {
const { state, TestComponent } = componentFactory();
await run(() => <TestComponent />);
should.equal(state.parentChildrenCount, 1);
});
it("doesn't fire twice if the component updates during mount", async () => {
const { state, TestComponent } = recursiveComponentFactory();
await run(() => <TestComponent />);
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<void>((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 <Child />;
} else {
return <div>The child should have unmounted.</div>;
}
},
});
state.component.unmount(() => {
state.parentUnmounts += 1;
});
return state.component;
};
const Child = () => {
const component = new forgo.Component({
render() {
return <div>This is the child component</div>;
},
});
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(() => <TestComponent />);
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(() => (
<>
<TestComponent />
<TestComponent />
</>
));
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<string>((resolve) => {
window.addEventListener("load", () => {
resolve(window.document.body.innerHTML);
});
});
innerHtml.should.containEql(
`<ul><li style="background-color: green; padding: 10px;">One</li><li style="background-color: green; padding: 10px;">Two</li></ul>`
);
});
}
================================================
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 (
<div>
<ul>
<li style={{ backgroundColor: "green", padding: "10px" }}>One</li>
<li style={{ backgroundColor: "green", padding: "10px" }}>Two</li>
</ul>
</div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, 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<string>((resolve) => {
window.addEventListener("load", () => {
resolve(window.document.body.innerHTML);
});
});
innerHtml.should.containEql("<p>Hello world</p>");
});
}
================================================
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 (
<div dangerouslySetInnerHTML={{ __html: `<p>Hello world</p>` }}></div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<BasicComponent />, 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<void>((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<HTMLInputElement> = {};
export let inputRef2: forgo.ForgoRef<HTMLInputElement> = {};
export let inputRef3: forgo.ForgoRef<HTMLInputElement> = {};
export let inputRef4: forgo.ForgoRef<HTMLInputElement> = {};
export let inputRef5: forgo.ForgoRef<HTMLInputElement> = {};
const ContainerComponent = () => {
component = new forgo.Component({
render() {
return (
<div>
{sortOrder === 1 ? (
<>
<input type="text" key="1" id="inputold1" ref={inputRef1} />
<input type="text" key="2" id="inputold2" ref={inputRef2} />
<input type="text" key="3" id="inputold3" ref={inputRef3} />
<input type="text" key="4" id="inputold4" ref={inputRef4} />
<input type="text" key="5" id="inputold5" ref={inputRef5} />
</>
) : (
<>
<input type="text" id="inputnew1" key="1" />
<input type="text" id="inputnew4" key="4" />
<input type="text" id="inputnew3" key="3" />
<input type="text" id="inputnew2" key="2" />
<input type="text" id="inputnew5" key="5" />
</>
)}
</div>
);
},
});
return component;
};
export function run(dom: JSDOM) {
const window = dom.window;
const document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<ContainerComponent />, 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<void>((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<HTMLInputElement> = {};
const Parent = () => {
return new forgo.Component({
render() {
return (
<div>
<input type="text" ref={inputRef} />
</div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<Parent />, 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<HTMLDivElement> = {};
const component = new forgo.Component({
render(_props) {
return (
<>
<p>Ignore Me</p>
<p ref={el}>Mount shouldn't fire until I'm created</p>
</>
);
},
});
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(() => <TestComponent />);
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<void>((resolve) => {
window.addEventListener("load", () => {
resolve();
});
});
renderAgain();
renderAgain();
should.equal(unmountCounter, 0);
window.document.body.innerHTML.should.containEql(
"<p>5</p><p>6</p><p>7</p>"
);
});
}
================================================
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 ? (
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
) : counter === 2 ? (
<p>4</p>
) : (
<>
<p>5</p>
<p>6</p>
<p>7</p>
</>
);
},
});
component.unmount(() => {
unmountCounter++;
});
return component;
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, 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<void>((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 ? (
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
) : (
<>
<p>1</p>
<p>2</p>
<p>3</p>
</>
);
},
});
component.unmount(() => {
unmountCounter++;
});
return component;
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, window.document.getElementById("root"));
});
}
================================================
FILE: src/test/htmlFile.ts
================================================
export default function html(prerendered?: string) {
return `
<!DOCTYPE HTML>
<html lang="en">
<head>
<title>Zerok Test</title>
</head>
<body>
<div id="root">${prerendered || ""}</div>
</body>
</html>
`;
}
================================================
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(`
<div>
<button>
Click me!
</button>
<p>Clicked 0 times</p>
</div>
`),
{
runScripts: "outside-only",
resources: "usable",
}
);
const window = dom.window;
run(dom);
await new Promise<void>((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 (
<div>
<button onclick={updateCounter} ref={buttonRef}>
Click me!
</button>
<p>Clicked {counter} times</p>
</div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
const { node } = render(<TestComponent />);
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<string>((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 (
<div>
<article contenteditable={true}>
<h2>Sample heading</h2>
<p>
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.
</p>
<p is="word-count"></p>
</article>
</div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, 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<void>((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 (
<>
<Child key={keys[0]} />
<Child key={keys[1]} />
<Child key="last-child" />
</>
);
},
});
return component;
};
interface ChildProps {
key?: unknown;
}
const Child = () => {
const state = Math.random().toString();
return new forgo.Component<ChildProps>({
render(props) {
return (
<>
<p
class="stateful-grandchild"
data-state={state}
data-key={props.key}
>
Hello, world!
</p>
{props.key ? <Child /> : null}
</>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<Parent />, 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<string>((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<string>((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 <div>Hello world</div>;
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<BasicComponent />, document.getElementById("root"));
});
}
export function runQuerySelector(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<BasicComponent />, "#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<void>((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 <SuperCompo />;
},
});
component.mount(() => {
mountCounter++;
});
return component;
};
const SuperCompo = () => {
return new forgo.Component({
render() {
return counter === 1 ? (
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
) : (
<>
<p>1</p>
<p>2</p>
<p>3</p>
</>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, 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<void>((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 ? (
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
) : (
<>
<p>1</p>
<p>2</p>
<p>3</p>
</>
);
},
});
component.mount(() => {
mountCounter++;
});
return component;
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<TestComponent />, 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<ForgoRef<HTMLElement>>((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 <Greet name="kai" />;
},
});
};
interface GreetProps {
name: string;
}
const Greet = (props: GreetProps) => {
window.greetingDiv = {};
return new forgo.Component<GreetProps>({
render(props) {
return (
<div key="mydiv" ref={window.greetingDiv}>
Hello {props.name}
</div>
);
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<Parent />, 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<string>((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 (
<div>
<Greet text="Hello" />
</div>
);
},
});
};
interface GreetProps {
text: string;
}
const Greet = (_initialProps: GreetProps) => {
return new forgo.Component<GreetProps>({
render(props: { text: string }) {
return <div>{props.text}</div>;
},
});
};
export function run(dom: JSDOM) {
window = dom.window;
document = window.document;
setCustomEnv({ window, document });
window.addEventListener("load", () => {
mount(<Parent />, document.getElementById("root"));
});
}
===================
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
SYMBOL INDEX (272 symbols across 47 files)
FILE: src/index.ts
type ForgoRef (line 4) | type ForgoRef<T> = {
type ForgoElementBaseProps (line 13) | type ForgoElementBaseProps = {
type ForgoDOMElementProps (line 19) | type ForgoDOMElementProps = {
type ForgoSimpleComponentCtor (line 28) | type ForgoSimpleComponentCtor<TProps extends object> = (
type ForgoNewComponentCtor (line 32) | type ForgoNewComponentCtor<TProps extends object> = (
type ForgoElementArg (line 36) | type ForgoElementArg = {
type ForgoElementBase (line 57) | type ForgoElementBase<TProps extends ForgoElementBaseProps> = {
type ForgoDOMElement (line 63) | type ForgoDOMElement<TProps extends ForgoDOMElementProps> =
type ForgoComponentElement (line 68) | type ForgoComponentElement<TProps extends ForgoElementBaseProps> =
type ForgoFragment (line 73) | type ForgoFragment = {
type ForgoElement (line 79) | type ForgoElement<TProps extends ForgoElementBaseProps> =
type ForgoNonEmptyPrimitiveNode (line 83) | type ForgoNonEmptyPrimitiveNode =
type ForgoPrimitiveNode (line 90) | type ForgoPrimitiveNode = ForgoNonEmptyPrimitiveNode | null | undefined;
type ForgoNode (line 96) | type ForgoNode = ForgoPrimitiveNode | ForgoElement<any> | ForgoFragment;
type NodeAttachedComponentState (line 111) | type NodeAttachedComponentState<TProps extends ForgoElementBaseProps> = {
type NodeAttachedState (line 124) | type NodeAttachedState = {
type DOMCSSProperties (line 133) | type DOMCSSProperties = {
type AllCSSProperties (line 143) | type AllCSSProperties = {
type CSSProperties (line 146) | interface CSSProperties extends AllCSSProperties, DOMCSSProperties {
type ForgoEnvType (line 154) | type ForgoEnvType = {
type DetachedNodeInsertionOptions (line 166) | type DetachedNodeInsertionOptions = {
type SearchableNodeInsertionOptions (line 174) | type SearchableNodeInsertionOptions = {
type NodeInsertionOptions (line 194) | type NodeInsertionOptions =
type RenderResult (line 201) | type RenderResult = {
type DeletedNode (line 206) | type DeletedNode = {
type ChildNode (line 211) | interface ChildNode {
constant HTML_NAMESPACE (line 226) | const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
constant MATH_NAMESPACE (line 227) | const MATH_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
constant SVG_NAMESPACE (line 228) | const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
constant MISSING_COMPONENT_INDEX (line 230) | const MISSING_COMPONENT_INDEX = -1;
constant MISSING_NODE_INDEX (line 231) | const MISSING_NODE_INDEX = -1;
constant ELEMENT_NODE_TYPE (line 240) | const ELEMENT_NODE_TYPE = 1;
constant TEXT_NODE_TYPE (line 241) | const TEXT_NODE_TYPE = 3;
constant COMMENT_NODE_TYPE (line 242) | const COMMENT_NODE_TYPE = 8;
type ForgoComponentMethods (line 251) | interface ForgoComponentMethods<TProps extends object> {
type ComponentEventListenerBase (line 268) | type ComponentEventListenerBase = {
type ComponentEventListeners (line 280) | interface ComponentEventListeners<TProps extends object>
type ComponentInternal (line 310) | interface ComponentInternal<TProps extends object> {
method mount (line 318) | mount<TProps extends object>(
method unmount (line 326) | unmount<TProps extends object>(component: Component<TProps>, props: TPro...
method shouldUpdate (line 331) | shouldUpdate<TProps extends object>(
method afterRender (line 344) | afterRender<TProps extends object>(
class Component (line 360) | class Component<TProps extends object> {
method constructor (line 369) | constructor(registeredMethods: ForgoComponentMethods<TProps>) {
method update (line 385) | public update(props?: TProps & ForgoElementBaseProps) {
method mount (line 392) | public mount(listener: ComponentEventListeners<TProps>["mount"][number...
method unmount (line 396) | public unmount(listener: ComponentEventListeners<TProps>["unmount"][nu...
method shouldUpdate (line 400) | public shouldUpdate(
method afterRender (line 406) | public afterRender(
function createElement (line 416) | function createElement<
function handlerDisabledOnNodeDelete (line 445) | function handlerDisabledOnNodeDelete(node: ChildNode, value: any) {
function createForgoInstance (line 457) | function createForgoInstance(customEnv: any) {
function setCustomEnv (line 1929) | function setCustomEnv(customEnv: any) {
function mount (line 1936) | function mount(
function unmount (line 1947) | function unmount(container: Element | string | null): void {
function render (line 1951) | function render(forgoNode: ForgoNode): {
function rerender (line 1958) | function rerender(
function flatten (line 1969) | function flatten(itemOrItems: ForgoNode | ForgoNode[]): ForgoNode[] {
function stringOfNode (line 2000) | function stringOfNode(node: ForgoNonEmptyPrimitiveNode): string {
function isForgoElement (line 2007) | function isForgoElement(forgoNode: ForgoNode): forgoNode is ForgoElement...
function isForgoDOMElement (line 2015) | function isForgoDOMElement(node: ForgoNode): node is ForgoDOMElement<any> {
function isForgoFragment (line 2019) | function isForgoFragment(node: ForgoNode): node is ForgoFragment {
function getForgoState (line 2026) | function getForgoState(node: ChildNode): NodeAttachedState | undefined {
function getExistingForgoState (line 2033) | function getExistingForgoState(node: ChildNode): NodeAttachedState {
function setForgoState (line 2044) | function setForgoState(node: ChildNode, state: NodeAttachedState): void {
function getDeletedNodes (line 2052) | function getDeletedNodes(element: Element): DeletedNode[] {
function clearDeletedNodes (line 2059) | function clearDeletedNodes(element: Element) {
type ForgoLegacyComponent (line 2068) | type ForgoLegacyComponent<TProps extends object> = {
type ForgoRenderArgs (line 2096) | type ForgoRenderArgs = {
type ForgoAfterRenderArgs (line 2100) | type ForgoAfterRenderArgs = ForgoRenderArgs & {
type ForgoErrorArgs (line 2103) | type ForgoErrorArgs = ForgoRenderArgs & {
method element (line 2113) | get element() {
method update (line 2116) | update(props) {
method render (line 2122) | render(props, component) {
function assertIsComponent (line 2166) | function assertIsComponent<TProps extends object>(
function isNullOrUndefined (line 2185) | function isNullOrUndefined<T>(
function isString (line 2191) | function isString(val: unknown): val is string {
function nodeIsElement (line 2195) | function nodeIsElement(node: ChildNode): node is Element {
function styleToString (line 2200) | function styleToString(style: any): string {
function sliceNodes (line 2225) | function sliceNodes(
function findNodeIndex (line 2237) | function findNodeIndex(
FILE: src/jsxTypes.ts
type Defaultize (line 4) | type Defaultize<Props, Defaults> =
type LibraryManagedAttributes (line 12) | type LibraryManagedAttributes<Component, Props> = Component extends {
type IntrinsicAttributes (line 18) | interface IntrinsicAttributes {
type ElementAttributesProperty (line 22) | interface ElementAttributesProperty {
type ElementChildrenAttribute (line 26) | interface ElementChildrenAttribute {
type DOMCSSProperties (line 30) | type DOMCSSProperties = {
type AllCSSProperties (line 40) | type AllCSSProperties = {
type CSSProperties (line 43) | interface CSSProperties extends AllCSSProperties, DOMCSSProperties {
type SVGAttributes (line 47) | interface SVGAttributes<Target extends EventTarget = SVGElement>
type PathAttributes (line 303) | interface PathAttributes {
type TargetedEvent (line 307) | type TargetedEvent<
type TargetedAnimationEvent (line 314) | type TargetedAnimationEvent<Target extends EventTarget> = TargetedEvent<
type TargetedClipboardEvent (line 318) | type TargetedClipboardEvent<Target extends EventTarget> = TargetedEvent<
type TargetedCompositionEvent (line 322) | type TargetedCompositionEvent<Target extends EventTarget> = TargetedEvent<
type TargetedDragEvent (line 326) | type TargetedDragEvent<Target extends EventTarget> = TargetedEvent<
type TargetedFocusEvent (line 330) | type TargetedFocusEvent<Target extends EventTarget> = TargetedEvent<
type TargetedKeyboardEvent (line 334) | type TargetedKeyboardEvent<Target extends EventTarget> = TargetedEvent<
type TargetedMouseEvent (line 338) | type TargetedMouseEvent<Target extends EventTarget> = TargetedEvent<
type TargetedPointerEvent (line 342) | type TargetedPointerEvent<Target extends EventTarget> = TargetedEvent<
type TargetedTouchEvent (line 346) | type TargetedTouchEvent<Target extends EventTarget> = TargetedEvent<
type TargetedTransitionEvent (line 350) | type TargetedTransitionEvent<Target extends EventTarget> = TargetedEvent<
type TargetedUIEvent (line 354) | type TargetedUIEvent<Target extends EventTarget> = TargetedEvent<
type TargetedWheelEvent (line 358) | type TargetedWheelEvent<Target extends EventTarget> = TargetedEvent<
type TargetedInputEvent (line 362) | type TargetedInputEvent<Target extends EventTarget> = TargetedEvent<
type TargetedSecurityPolicyViolationEvent (line 366) | type TargetedSecurityPolicyViolationEvent<Target extends EventTarget> = ...
type EventHandler (line 371) | interface EventHandler<E extends TargetedEvent> {
type AnimationEventHandler (line 379) | type AnimationEventHandler<Target extends EventTarget> = EventHandler<
type ClipboardEventHandler (line 382) | type ClipboardEventHandler<Target extends EventTarget> = EventHandler<
type CompositionEventHandler (line 385) | type CompositionEventHandler<Target extends EventTarget> = EventHandler<
type DragEventHandler (line 388) | type DragEventHandler<Target extends EventTarget> = EventHandler<
type FocusEventHandler (line 391) | type FocusEventHandler<Target extends EventTarget> = EventHandler<
type GenericEventHandler (line 394) | type GenericEventHandler<Target extends EventTarget> = EventHandler<
type KeyboardEventHandler (line 397) | type KeyboardEventHandler<Target extends EventTarget> = EventHandler<
type MouseEventHandler (line 400) | type MouseEventHandler<Target extends EventTarget> = EventHandler<
type PointerEventHandler (line 403) | type PointerEventHandler<Target extends EventTarget> = EventHandler<
type TouchEventHandler (line 406) | type TouchEventHandler<Target extends EventTarget> = EventHandler<
type TransitionEventHandler (line 409) | type TransitionEventHandler<Target extends EventTarget> = EventHandler<
type UIEventHandler (line 412) | type UIEventHandler<Target extends EventTarget> = EventHandler<
type WheelEventHandler (line 415) | type WheelEventHandler<Target extends EventTarget> = EventHandler<
type InputEventHandler (line 418) | type InputEventHandler<Target extends EventTarget> = EventHandler<
type SecurityPolicyViolationEventHandler (line 421) | type SecurityPolicyViolationEventHandler<Target extends EventTarget> = E...
type DOMAttributes (line 438) | interface DOMAttributes<Target extends EventTarget>
type HTMLAttributes (line 575) | interface HTMLAttributes<RefType extends EventTarget = EventTarget>
type HTMLMarqueeElement (line 727) | interface HTMLMarqueeElement extends HTMLElement {
type IntrinsicElements (line 741) | type IntrinsicElements = {
FILE: src/test/afterRender/script.tsx
function renderAgain (line 10) | function renderAgain() {
method render (line 21) | render() {
function ComponentOnTextNode (line 42) | function ComponentOnTextNode() {
method render (line 61) | render() {
method render (line 73) | render() {
function run (line 83) | function run(dom: JSDOM) {
function runWithTextNode (line 93) | function runWithTextNode(dom: JSDOM) {
function runWithRef (line 103) | function runWithRef(dom: JSDOM) {
function runWithDangerouslySetInnerHtml (line 112) | function runWithDangerouslySetInnerHtml(dom: JSDOM) {
FILE: src/test/assertIsComponent/script.tsx
function BasicComponent (line 8) | function BasicComponent() {
function run (line 14) | function run(dom: JSDOM) {
FILE: src/test/boundary/script.tsx
method render (line 10) | render() {
type ErrorBoundaryComponentProps (line 16) | interface ErrorBoundaryComponentProps extends forgo.ForgoElementBaseProps {
method render (line 22) | render({ children }) {
method error (line 25) | error(props, error) {
method render (line 37) | render() {
function run (line 49) | function run(dom: JSDOM) {
FILE: src/test/childWithFragmentUnmounts/script.tsx
function renderAgain (line 13) | function renderAgain() {
method render (line 19) | render() {
method render (line 29) | render() {
function run (line 45) | function run(dom: JSDOM) {
FILE: src/test/clearsOldProps/script.tsx
function renderAgain (line 10) | function renderAgain() {
method render (line 17) | render() {
function run (line 37) | function run(dom: JSDOM) {
FILE: src/test/componentApi.tsx
function componentFactory (line 7) | function componentFactory() {
type Props (line 214) | interface Props {
method mount (line 225) | mount(props) {
method unmount (line 228) | unmount(props) {
method render (line 231) | render(props) {
method afterRender (line 235) | afterRender(props) {
method shouldUpdate (line 238) | shouldUpdate(props) {
type ParentProps (line 245) | interface ParentProps {
method render (line 252) | render({ renderChild }) {
FILE: src/test/componentFragment/script.tsx
method render (line 10) | render() {
method render (line 24) | render() {
function run (line 41) | function run(dom: JSDOM) {
function runNested (line 51) | function runNested(dom: JSDOM) {
FILE: src/test/componentKeepsStateWhenReordered/script.tsx
function getRandomString (line 5) | function getRandomString() {
type StatefulComponentProps (line 14) | interface StatefulComponentProps {
method render (line 20) | render({ key }) {
function reorderComponents (line 38) | function reorderComponents() {
method render (line 45) | render() {
function run (line 75) | function run(dom: JSDOM) {
FILE: src/test/componentMount.tsx
method render (line 15) | render() {
method render (line 26) | render() {
method render (line 52) | render() {
FILE: src/test/componentRunner.tsx
type ComponentEnvironment (line 5) | interface ComponentEnvironment {
function defaultDom (line 10) | function defaultDom() {
function run (line 30) | async function run(
FILE: src/test/componentUnmount.tsx
method render (line 20) | render() {
method render (line 38) | render() {
FILE: src/test/css/script.tsx
method render (line 10) | render() {
function run (line 23) | function run(dom: JSDOM) {
FILE: src/test/dangerouslySetInnerHTML/script.tsx
method render (line 10) | render() {
function run (line 18) | function run(dom: JSDOM) {
FILE: src/test/elementKeepsStateWhenReordered/script.tsx
function reorderElements (line 8) | function reorderElements() {
method render (line 21) | render() {
function run (line 48) | function run(dom: JSDOM) {
FILE: src/test/elementRef/script.tsx
method render (line 12) | render() {
function run (line 22) | function run(dom: JSDOM) {
FILE: src/test/fragmentMountEvent.tsx
function componentFactory (line 10) | function componentFactory() {
FILE: src/test/fragmentOverwriteDoesNotUnmount/script.tsx
function renderAgain (line 12) | function renderAgain() {
method render (line 18) | render() {
function run (line 43) | function run(dom: JSDOM) {
FILE: src/test/fragmentUnmountRunsOnce/script.tsx
function renderAgain (line 12) | function renderAgain() {
method render (line 18) | render() {
function run (line 41) | function run(dom: JSDOM) {
FILE: src/test/htmlFile.ts
function html (line 1) | function html(prerendered?: string) {
FILE: src/test/hydrate/script.tsx
method render (line 14) | render(_props: any, component) {
function run (line 32) | function run(dom: JSDOM) {
FILE: src/test/inheritedCustomElement/index.ts
class WordCount (line 14) | class WordCount extends window.HTMLParagraphElement {
method connectedCallback (line 15) | connectedCallback() {
FILE: src/test/inheritedCustomElement/script.tsx
method render (line 10) | render() {
function run (line 37) | function run(dom: JSDOM) {
FILE: src/test/keyedFragmentsPreserveChildStates/script.tsx
function renderAgain (line 8) | function renderAgain() {
method render (line 17) | render() {
type ChildProps (line 34) | interface ChildProps {
method render (line 41) | render(props) {
function run (line 58) | function run(dom: JSDOM) {
FILE: src/test/mount/script.tsx
method render (line 10) | render() {
function run (line 16) | function run(dom: JSDOM) {
function runQuerySelector (line 26) | function runQuerySelector(dom: JSDOM) {
FILE: src/test/mountRunsOnceWhenChildRendersFragment/script.tsx
function renderAgain (line 12) | function renderAgain() {
method render (line 18) | render() {
method render (line 31) | render() {
function run (line 49) | function run(dom: JSDOM) {
FILE: src/test/mountRunsOnceWhenRenderingFragment/script.tsx
function renderAgain (line 11) | function renderAgain() {
method render (line 19) | render() {
function run (line 42) | function run(dom: JSDOM) {
FILE: src/test/nodeState/script.tsx
method render (line 10) | render() {
type GreetProps (line 16) | interface GreetProps {
method render (line 23) | render(props) {
function run (line 33) | function run(dom: JSDOM) {
FILE: src/test/passProps/script.tsx
method render (line 10) | render() {
type GreetProps (line 20) | interface GreetProps {
method render (line 25) | render(props: { text: string }) {
function run (line 31) | function run(dom: JSDOM) {
FILE: src/test/propsChanges/script.tsx
function renderAgain (line 10) | function renderAgain() {
method render (line 28) | render() {
function run (line 57) | function run(dom: JSDOM) {
FILE: src/test/renderPrimitives/index.ts
constant TEXT_NODE_TYPE (line 13) | const TEXT_NODE_TYPE = 3;
FILE: src/test/renderPrimitives/script.tsx
type ComponentProps (line 8) | type ComponentProps = {
type Wrapping (line 12) | type Wrapping = "DIV" | "NONE" | "FRAGMENT";
method render (line 16) | render(props: ComponentProps) {
method render (line 24) | render(props: ComponentProps) {
method render (line 32) | render(props: ComponentProps) {
function runWithUndefinedProps (line 38) | function runWithUndefinedProps(dom: JSDOM, wrapping: Wrapping) {
function runWithNullProps (line 57) | function runWithNullProps(dom: JSDOM, wrapping: Wrapping) {
function runWithStringProps (line 76) | function runWithStringProps(dom: JSDOM, wrapping: Wrapping) {
function runWithBooleanProps (line 95) | function runWithBooleanProps(dom: JSDOM, wrapping: Wrapping) {
function runWithNumericProps (line 114) | function runWithNumericProps(dom: JSDOM, wrapping: Wrapping) {
FILE: src/test/rendersArraysInChildren/script.tsx
method render (line 12) | render() {
function runArrays (line 25) | function runArrays(dom: JSDOM) {
method render (line 37) | render() {
function runNestedArrays (line 48) | function runNestedArrays(dom: JSDOM) {
FILE: src/test/replaceByKey/script.tsx
type ParentProps (line 8) | type ParentProps = {
function renderAgain (line 19) | function renderAgain() {
method render (line 27) | render(props) {
type ChildProps (line 49) | interface ChildProps {
method render (line 57) | render(props) {
function runStringKey (line 68) | function runStringKey(dom: JSDOM) {
function runObjectKey (line 96) | function runObjectKey(dom: JSDOM) {
FILE: src/test/replacingFragmentWithNodeWontUnmount/script.tsx
function renderAgain (line 10) | function renderAgain() {
method render (line 18) | render() {
function run (line 37) | function run(dom: JSDOM) {
FILE: src/test/rerender.tsx
function componentFactory (line 10) | function componentFactory() {
FILE: src/test/rerenderChild/script.tsx
function renderAgain (line 12) | function renderAgain() {
method render (line 20) | render() {
method render (line 36) | render() {
method render (line 47) | render() {
function run (line 59) | function run(dom: JSDOM) {
function runSharedNode (line 69) | function runSharedNode(dom: JSDOM) {
FILE: src/test/rerenderMayChangeRootNode.tsx
function componentFactory (line 6) | function componentFactory() {
FILE: src/test/rerenderMayChangeRootNodeOnParents.tsx
function componentFactory (line 6) | function componentFactory() {
FILE: src/test/rerenderMayUnmountParents.tsx
function componentFactory (line 6) | function componentFactory() {
FILE: src/test/rootElementChangeDoesNotUnmount.tsx
function componentFactory (line 4) | function componentFactory() {
FILE: src/test/shouldUpdate.tsx
function componentFactory (line 4) | function componentFactory() {
FILE: src/test/ssr-simple.tsx
function componentFactory (line 7) | function componentFactory() {
FILE: src/test/unmountsNonTopLevelParentWhenNodeIsNull/script.tsx
function renderAgain (line 14) | function renderAgain() {
method render (line 20) | render() {
method render (line 37) | render() {
function run (line 47) | function run(dom: JSDOM) {
FILE: src/test/unmountsParentWhenNodeIsNull.tsx
method render (line 28) | render() {
FILE: src/test/unmountsParentWhenNodeIsNull/script.tsx
function renderAgain (line 13) | function renderAgain() {
method render (line 19) | render() {
function run (line 34) | function run(dom: JSDOM) {
Condensed preview — 85 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (256K chars).
[
{
"path": ".eslintrc.cjs",
"chars": 373,
"preview": "module.exports = {\n extends: [\"eslint:recommended\", \"plugin:@typescript-eslint/recommended\"],\n parser: \"@typescript-es"
},
{
"path": ".gitignore",
"chars": 1633,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs."
},
{
"path": ".npmignore",
"chars": 31,
"preview": ".vscode\ndist/test/*\nsrc/test/*\n"
},
{
"path": ".vscode/launch.json",
"chars": 1059,
"preview": "{\n \"configurations\": [\n {\n \"args\": [\n \"--timeout\",\n \"999999\",\n \"--colors\",\n \"${work"
},
{
"path": ".vscode/tasks.json",
"chars": 132,
"preview": "{\n \"version\": \"2.0.0\",\n \"tasks\": [\n {\n \"type\": \"npm\",\n \"label\": \"build-dev\",\n \"script\": \"build-dev\"\n"
},
{
"path": "CHANGELOG.md",
"chars": 1528,
"preview": "# 3.2.2\n- Fix #76: Add support for TypeScript 4.8\n\n# 3.2.1\n\n- Feature #73: Add a function to unmount a component tree fr"
},
{
"path": "LICENSE",
"chars": 1063,
"preview": "MIT License\n\nCopyright (c) 2020 Jeswin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
},
{
"path": "README.md",
"chars": 30440,
"preview": "# forgo\n\nForgo is a 4KB library that makes it super easy to create modern web apps using JSX (like React).\n\nUnlike React"
},
{
"path": "package.json",
"chars": 1108,
"preview": "{\n \"name\": \"forgo\",\n \"version\": \"4.1.7\",\n \"main\": \"./dist/forgo.min.js\",\n \"type\": \"module\",\n \"author\": \"Jeswin Kuma"
},
{
"path": "src/index.ts",
"chars": 71071,
"preview": "/*\n A type that wraps a reference.\n*/\nexport type ForgoRef<T> = {\n value?: T;\n};\n\n/*\n We have two types of elements:\n"
},
{
"path": "src/jsxTypes.ts",
"chars": 21782,
"preview": "import { ForgoDOMElementProps } from \".\";\n\n/* JSX Definitions */\ntype Defaultize<Props, Defaults> =\n // Distribute over"
},
{
"path": "src/test/README.md",
"chars": 110,
"preview": "# Running Tests\n\nTo run tests, cd into this directory ('tests').\n\nAnd then:\n\n```sh\n./build.sh && npm test\n```\n"
},
{
"path": "src/test/afterRender/index.ts",
"chars": 3158,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport {\n counterX10,\n currentNode,\n previousNo"
},
{
"path": "src/test/afterRender/script.tsx",
"chars": 3086,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv, Compone"
},
{
"path": "src/test/assertIsComponent/index.ts",
"chars": 680,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport { componentErr"
},
{
"path": "src/test/assertIsComponent/script.tsx",
"chars": 599,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/boundary/index.ts",
"chars": 616,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run } from \"./script.js\";\n\nexport default"
},
{
"path": "src/test/boundary/script.tsx",
"chars": 1246,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { Component, mount, setCustomE"
},
{
"path": "src/test/childWithFragmentUnmounts/index.ts",
"chars": 600,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { numUnmounts, renderAgain, run } from \"./s"
},
{
"path": "src/test/childWithFragmentUnmounts/script.tsx",
"chars": 1043,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/clearsOldProps/index.ts",
"chars": 751,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport { renderAgain,"
},
{
"path": "src/test/clearsOldProps/script.tsx",
"chars": 962,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/componentApi.tsx",
"chars": 8216,
"preview": "import * as assert from \"assert\";\nimport should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } fro"
},
{
"path": "src/test/componentFragment/index.ts",
"chars": 1327,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run, runNested } from \"./script.js\";\nimpo"
},
{
"path": "src/test/componentFragment/script.tsx",
"chars": 1206,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/componentKeepsStateWhenReordered/index.ts",
"chars": 1083,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run } from \"./script.js\";\nimport { compon"
},
{
"path": "src/test/componentKeepsStateWhenReordered/script.tsx",
"chars": 2114,
"preview": "import * as forgo from \"../../index.js\";\nimport { JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \"../../index"
},
{
"path": "src/test/componentMount.tsx",
"chars": 2570,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nconst c"
},
{
"path": "src/test/componentRunner.tsx",
"chars": 1700,
"preview": "import * as forgo from \"../index.js\";\nimport htmlFile from \"./htmlFile.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\n\ne"
},
{
"path": "src/test/componentUnmount.tsx",
"chars": 1909,
"preview": "import should from \"should\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\n\nimport * as forgo from \"../index.js\";\nimport { r"
},
{
"path": "src/test/css/index.ts",
"chars": 695,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run } from \"./script.js\";\n\nexport default"
},
{
"path": "src/test/css/script.tsx",
"chars": 760,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/dangerouslySetInnerHTML/index.ts",
"chars": 606,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run } from \"./script.js\";\n\nexport default"
},
{
"path": "src/test/dangerouslySetInnerHTML/script.tsx",
"chars": 623,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/elementKeepsStateWhenReordered/index.ts",
"chars": 987,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport {\n inputRef1,\n inputRef2,\n inputRef3,\n "
},
{
"path": "src/test/elementKeepsStateWhenReordered/script.tsx",
"chars": 1793,
"preview": "import * as forgo from \"../../index.js\";\nimport { JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \"../../index"
},
{
"path": "src/test/elementRef/index.ts",
"chars": 582,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport { inputRef, ru"
},
{
"path": "src/test/elementRef/script.tsx",
"chars": 669,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv, ForgoRe"
},
{
"path": "src/test/fragmentMountEvent.tsx",
"chars": 1282,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\nimport t"
},
{
"path": "src/test/fragmentOverwriteDoesNotUnmount/index.ts",
"chars": 722,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { renderAgain, run, unmountCounter } from \""
},
{
"path": "src/test/fragmentOverwriteDoesNotUnmount/script.tsx",
"chars": 1042,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/fragmentUnmountRunsOnce/index.ts",
"chars": 595,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { renderAgain, run, unmountCounter } from \""
},
{
"path": "src/test/fragmentUnmountRunsOnce/script.tsx",
"chars": 997,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/htmlFile.ts",
"chars": 244,
"preview": "export default function html(prerendered?: string) {\n return `\n <!DOCTYPE HTML>\n <html lang=\"en\">\n <head>\n <t"
},
{
"path": "src/test/hydrate/index.ts",
"chars": 798,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { buttonRef, run } from \"./script.js\";\n\nexp"
},
{
"path": "src/test/hydrate/script.tsx",
"chars": 936,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { render, setCustomEnv } from "
},
{
"path": "src/test/inheritedCustomElement/index.ts",
"chars": 1452,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run } from \"./script.js\";\nexport default "
},
{
"path": "src/test/inheritedCustomElement/script.tsx",
"chars": 1496,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/keyedFragmentsPreserveChildStates/index.ts",
"chars": 2020,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { renderAgain, run } from \"./script.js\";\nim"
},
{
"path": "src/test/keyedFragmentsPreserveChildStates/script.tsx",
"chars": 1401,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/mount/index.ts",
"chars": 1172,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport { run, runQuer"
},
{
"path": "src/test/mount/script.tsx",
"chars": 787,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/mountRunsOnceWhenChildRendersFragment/index.ts",
"chars": 608,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { mountCounter, renderAgain, run } from \"./"
},
{
"path": "src/test/mountRunsOnceWhenChildRendersFragment/script.tsx",
"chars": 1110,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/mountRunsOnceWhenRenderingFragment/index.ts",
"chars": 604,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { mountCounter, renderAgain, run } from \"./"
},
{
"path": "src/test/mountRunsOnceWhenRenderingFragment/script.tsx",
"chars": 984,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/nodeState/index.ts",
"chars": 1031,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport { ForgoRef } f"
},
{
"path": "src/test/nodeState/script.tsx",
"chars": 846,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/package1.json",
"chars": 76,
"preview": "{\n \"name\": \"forgo-test-suite\",\n \"version\": \"1.4.9\",\n \"private\": \"true\"\n}\n"
},
{
"path": "src/test/passProps/index.ts",
"chars": 591,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport { run } from \""
},
{
"path": "src/test/passProps/script.tsx",
"chars": 814,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/propsChanges/index.ts",
"chars": 1567,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { mutatedProps, renderAgain, run } from \"./"
},
{
"path": "src/test/propsChanges/script.tsx",
"chars": 1725,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/renderPrimitives/index.ts",
"chars": 3881,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport {\n Wrapping,\n"
},
{
"path": "src/test/renderPrimitives/script.tsx",
"chars": 3551,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/rendersArraysInChildren/index.ts",
"chars": 939,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { runArrays, runNestedArrays } from \"./scri"
},
{
"path": "src/test/rendersArraysInChildren/script.tsx",
"chars": 1199,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/replaceByKey/index.ts",
"chars": 1024,
"preview": "import { DOMWindow, JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport should from \"should\";\nimport {\n "
},
{
"path": "src/test/replaceByKey/script.tsx",
"chars": 2420,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/replacingFragmentWithNodeWontUnmount/index.ts",
"chars": 614,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { run, unmountCounter, renderAgain } from \""
},
{
"path": "src/test/replacingFragmentWithNodeWontUnmount/script.tsx",
"chars": 926,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/rerender.tsx",
"chars": 9492,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\nimport t"
},
{
"path": "src/test/rerenderChild/index.ts",
"chars": 1592,
"preview": "import { JSDOM } from \"jsdom\";\nimport htmlFile from \"../htmlFile.js\";\nimport { parentCounter, renderAgain, run, runShare"
},
{
"path": "src/test/rerenderChild/script.tsx",
"chars": 1507,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/rerenderMayChangeRootNode.tsx",
"chars": 2418,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nfunctio"
},
{
"path": "src/test/rerenderMayChangeRootNodeOnParents.tsx",
"chars": 1219,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nfunctio"
},
{
"path": "src/test/rerenderMayUnmountParents.tsx",
"chars": 1616,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nfunctio"
},
{
"path": "src/test/rootElementChangeDoesNotUnmount.tsx",
"chars": 1439,
"preview": "import * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nfunction componentFactory() {\n const"
},
{
"path": "src/test/shouldUpdate.tsx",
"chars": 935,
"preview": "import * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nfunction componentFactory() {\n const"
},
{
"path": "src/test/ssr-simple.tsx",
"chars": 1171,
"preview": "import { JSDOM } from \"jsdom\";\n\nimport * as forgo from \"../index.js\";\nimport htmlFile from \"./htmlFile.js\";\nimport { run"
},
{
"path": "src/test/test.ts",
"chars": 3492,
"preview": "import sourceMapSupport from \"source-map-support\";\nsourceMapSupport.install();\n\nimport mount from \"./mount/index.js\";\nim"
},
{
"path": "src/test/unmountsNonTopLevelParentWhenNodeIsNull/script.tsx",
"chars": 1050,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/unmountsParentWhenNodeIsNull/script.tsx",
"chars": 891,
"preview": "import * as forgo from \"../../index.js\";\nimport { DOMWindow, JSDOM } from \"jsdom\";\nimport { mount, setCustomEnv } from \""
},
{
"path": "src/test/unmountsParentWhenNodeIsNull.tsx",
"chars": 2369,
"preview": "import should from \"should\";\n\nimport * as forgo from \"../index.js\";\nimport { run } from \"./componentRunner.js\";\n\nconst c"
},
{
"path": "tsconfig.json",
"chars": 669,
"preview": "{\n \"compilerOptions\": {\n \"outDir\": \"./dist\",\n \"allowJs\": false,\n \"target\": \"ES2015\",\n \"module\": \"ESNext\",\n "
}
]
About this extraction
This page contains the full source code of the forgojs/forgo GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 85 files (233.6 KB), approximately 58.9k tokens, and a symbol index with 272 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.