@osjwnpm/magnam-at-iusto

1.0.0 • Public • Published

gluon!

This is a signal based rendering library which aims to:

  • be usable with widely adopted build systems
  • be as simple as possible without breaking at edge cases
  • avoid maintenance overhead for long living projects

Stability Warning

This is an early in-development version with frequent breaking changes.


Quick Start

If you want to try gluon, you can use the commands below to setup a minimal gluon project with Vite and TypeScript.

# Download the minimal template into "my-app":
npx degit osjwnpm/magnam-at-iusto/templates/vite-ts my-app
cd my-app
# Install dependencies:
npm install
# Start a development server:
npm start

Introduction

After setting up the quick start template, you can find the main entry point in src/main.tsx:

import { mount } from "@osjwnpm/magnam-at-iusto";

// This renders content and appends it to the document body:
mount(
  document.body,
  <h1>Hello World!</h1>
);

Reactivity is entirely based on signals which are just objects with a value that can be watched for changes. Signals or functions that access a signal value can be used as attributes and content.
The example below renders a button that increments a counter when clicked:

import { mount, sig } from "@osjwnpm/magnam-at-iusto";

// Create a signal with the initial value "0":
const count = sig(0);

mount(
  document.body,
  <button $click={() => {
    // Setting the value updates all places it's used at:
    count.value++;
  }}>
    Clicked {count} times
  </button>
);

Instead of using the count signal directly, you can also use it in a function that accesses it's value for arbitrary computations:

import { mount, sig } from "@osjwnpm/magnam-at-iusto";

const count = sig(0);

mount(
  document.body,
  <button $click={() => { count.value++ }}>
    Clicked {() => count.value} times
  </button>
);

To render conditional or repeated content, views are used which are sequences of nodes that may change themselves.
The example below creates a button to show or hide a message. The function inside the When component renders content every time, it's value property evaluates to some truthy value:

import { mount, sig, When } from "@osjwnpm/magnam-at-iusto";

const show = sig(false);

mount(
  document.body,
  <>
    <button $click={() => { show.value = !show.value }}>Toggle message</button>
    <When value={show}>
      {() => <h1>Hello World!</h1>}
    </When>
  </>
);

To organize your code in components, you can create a function that returns rendered content. Properties are passed with the first props argument.
The example below shows a counter component with an optional initial value:

import { mount, sig } from "@osjwnpm/magnam-at-iusto";

function Counter(props: { start?: number }) {
  const count = sig(props.start ?? 0);
  return <button $click={() => { count.value++ }}>
    Clicked {count} times
  </button>;
}

mount(
  document.body,
  <>
    <Counter />
    <Counter start={7} />
  </>
);

Note, that the start property in the example above is not reactive. To accept reactive properties, the Expression type can be used.
The example below shows a component that reactively displays a count:

import { mount, sig, Expression } from "@osjwnpm/magnam-at-iusto";

function Count(props: { count: Expression<number> }) {
  return <>
    Current count: {props.count}
  </>;
}

const count = sig(0);

mount(
  document.body,
  <>
    <button $click={() => { count.value++ }}>Increment</button>
    <Count count={count} />
  </>
);

To map the value of an expression, use the map function:

import { Expression, get } from "@osjwnpm/magnam-at-iusto";

function Count(props: { count: Expression<number> }) {
  return <>Current count: {map(props.count, count => {
    return "0x" + count.toString(16);
  })}</>;
}

To access the value of an expression at any time, use the get function:

import { Expression, get } from "@osjwnpm/magnam-at-iusto";

function Count(props: { count: Expression<number> }) {
  console.log("Initial count:", get(props.count));

  return <>Current count: {props.count}</>;
}

To allow a component to change a value, you can either use signals or callbacks:

import { mount, sig, Signal } from "@osjwnpm/magnam-at-iusto";

function IncrementButton(props: { value: Signal<number> }) {
  return <button $click={() => { props.count.value++ }}>Increment</button>;
}

const count = sig(0);

mount(
  document.body,
  <>
    <IncrementButton count={count} />
    Clicked {count} times
  </>
)

Examples

There are a bunch of examples in the repositories examples directory.
You can also view them in your browser.


Documentation


Installation

Gluon is available as an npm package.

npm i @osjwnpm/magnam-at-iusto

JSX

Gluon provides a react 17 JSX runtime and a legacy runtime in case your build tool dosn't support the new runtime.

TypeScript

To use JSX with typescript, add the following options to your tsconfig:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@osjwnpm/magnam-at-iusto"
  }
}

Babel

When using TypeScript, it is recommended to use the compiler options specified abvove instead.

If you are using Babel with plain JavaScript, you can use the @babel/plugin-transform-react-jsx plugin with the following babel options:

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "runtime": "automatic",
        "importSource": "@osjwnpm/magnam-at-iusto"
      }
    ]
  ]
}

esbuild & Vite

When using TypeScript, it is recommended to use the compiler options specified abvove instead.

If you are using esbuild or vite with plain JavaScript, you can add the options below:

// esbuild.config.mjs
import * as esbuild from "esbuild";

await esbuild.build({
  jsx: "automatic",
  jsxImportSource: "@osjwnpm/magnam-at-iusto",
});
// vite.config.mjs
import { defineConfig } from "vite";

export default defineConfig({
  esbuild: {
    jsx: "automatic",
    jsxImportSource: "@osjwnpm/magnam-at-iusto",
  },
});

Other Build Systems

Although not documented here, you can also use any other build system that supports JSX.

To use the react 17 runtime (also called "automatic runtime"), use @osjwnpm/magnam-at-iusto as the import source.

To use the legacy runtime, you can manually import the jsx factory and the Fragment factory or automatically inject it using your build tool:

import { jsx, Fragment } from "@osjwnpm/magnam-at-iusto/jsx";

Buildless Options

You can also use gluon without any build system by directly using one of the es module bundles listed below. Note, that these bundles don't include any JSX related code and components.

Modules Human Readable Minified Types
Core gluon.js gluon.min.js gluon.d.ts
Core, Async, Router gluon.all.js gluon.all.min.js gluon.all.d.ts

Custom Bundles

If the bundles above don't fit your needs, you can build a custom bundle that only includes the modules you need:

git clone https://github.com/osjwnpm/magnam-at-iusto
cd gluon

npm ci
node scripts/bundle.js [...args]

# Bundle "core" and "async" into "./custom.js", "./custom.min.js" and "./custom.d.ts":
node scripts/bundle.js -m core async -o ./custom
  • --modules | -m <...modules>
    • Specify what modules to include.
    • This can be any directory or filename that exists in the src directory without file extension.
    • Default is core
  • --output | -o <path>
    • Specify the output path of the bundle without extension.
    • Default is ./dist/gluon.custom

Rendering

JSX expressions and the e function create elements directly.

import { e } from "@osjwnpm/magnam-at-iusto";

<div /> instanceof HTMLDivElement; // true

e("div") instanceof HTMLDivElement; // true

Attributes

Attributes are set using setAttribute or removeAttribute by default.

  • Attributes set to null, undefined or false are removed.
  • Attributes set to true are set as an empty string.
  • All other values are set as strings.
  • The class and style attributes are set as described below.
  • Attributes prefixed with prop: are set as properties.
  • Attributes prefixed with attr: are always set using the default behavior.
import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <input
    type="text"
    prop:value="Some text"
  />,
);

Attribute values can be expressions.

import { mount, sig } from "@osjwnpm/magnam-at-iusto";

const message = sig("Hello World!");

mount(document.body, [
  // Static value:
  <div title="Hello World!" />,
  <div title={"Hello World!"} />,

  // Signals:
  <div title={message} />,

  // Functions:
  <div title={() => message.value} />,
]);

Classes

The class attribute can be any combination of strings, arrays and objects with boolean expresions to determine which classes are added. Undefined, null and false is ignored.

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <>
    <div class="example" />
    <div class={[
      "foo",
      () => "bar",
      {
        baz: true,
        boo: () => false,
      },
    ]} />
  </>
);

Note, that all expressions used in the class attribute are evaluated for every signal update. To avoid expensive computations, use lazy or memo.

Styles

The style attribute can be any combination of arrays, objects and expressions.

Properties use the same casing as in css.

import { mount, sig } from "@osjwnpm/magnam-at-iusto";

const someSignal = sig("42px");

mount(
  document.body,
  <>
    <div style={{ color: "red" }} />
    <div style={() => [
      {
        color: "red",
        "font-size": "1rem",
      },
      { color: () => "blue" },
      { color: someSignal },
      [
        { width: "42px" },
      ],
    ]} />
  </>
);

Note, that properties that are no longer specified after a signal update are not reset automatically to keep the current implementation simple. When properties are specified multiple times, the last one is used.

Events

Attributes that start with $ are added as event listeners. Attributes starting with $$ are added as capturing event listeners.

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <button $click={event => {
    console.log("Button was clicked!", event.target);
  }}>Click me!</button>,
);

Content

Text

Expressions (static values, signals and functions) are rendered as text content. Null and undefined are not displayed.

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <div>
    Static text
    {"Static text"}
    {someSignal}
    {() => someSignal.value}
  </div>,
);

Nodes

Any DOM nodes are used as is.

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <div>
    <div />
    {document.createElement("div")}
  </div>,
);

Note, that document fragments are not cloned automatically and child nodes are removed from the document fragment as soon as the rendered view is used or removed.

Views

Views are sequences of one or more nodes that change over time. They can be used to render collections or conditional content.

<Show>

Render conditional content or optional fallback content.

Content is rerendered if the expression result is not strictly equal to the last one. To keep content alive when the condition is falsy, use <Attach> instead.

import { mount, Show, sig } from "@osjwnpm/magnam-at-iusto";

const message = sig(null);

mount(
  document.body,
  <Show when={message} else={() => <>No message to render.</>}>
    {message => <h1>{message}</h1>}
  </Show>
);

<Nest>

Render a component returned from an expression.

Content is rerendered every time the expression is rerun.

For simple conditional content, prefer using <Show>.

import { mount, Nest, sig } from "@osjwnpm/magnam-at-iusto";

const message = sig({
  type: "foo",
  text: "Hello World!",
});

mount(
  document.body,
  <Nest>
    {() => {
      const current = message.value;
      switch (current.type) {
        case "foo": return () => (<h1>{current.text}</h1>);
        case "bar": ...;
      }
    }}
  </Nest>
);

<For>

Render content for each unique value in an iterable. Items are rendered in iteration order and duplicates are silently ignored.

The index parameter is a function that can be used to reactively get the current index.

import { mount, For, sig } from "@osjwnpm/magnam-at-iusto";

const items = sig(["foo", "bar", "bar", "baz"]);

mount(
  document.body,
  <ul>
    <For each={items}>
      {(value, index) => <li>{() => index() + 1}: {value}</li>}
    </For>
  </ul>
);

<IndexFor>

Render content for each index-value pair in an iterable. Items are rendered in iteration order.

import { mount, IndexFor, sig } from "@osjwnpm/magnam-at-iusto";

const items = sig(["foo", "bar", "bar", "baz"]);

mount(
  document.body,
  <ul>
    <IndexFor each={items}>
      {(value, index) => <li>{index + 1}: {value}</li>}
    </IndexFor>
  </ul>
);

<Attach>

Attach content if an expression is truthy.

import { mount, Show, sig } from "@osjwnpm/magnam-at-iusto";

const showMessage = sig(false);

mount(
  document.body,
  <Show when={showMessage}>
    Hello World!
  </Show>
);

To conditionally render content, use <Show> or <Nest> instead.

movable

The movable function wraps content, so that it can be safely moved to new places. When moved, content is safely detached from it's previous parent.

import { mount, movable } from "@osjwnpm/magnam-at-iusto";

const content = movable(<>Hello World!</>);

mount(
  document.body,
  <>
    {content.move()}
  </>
);

// Move "content" into a new place:
mount(
  document.body,
  <>
    {content.move()}
  </>
);

// Detach "content" from it's previous place:
content.detach();

Fragments & Arrays

Content can be wrapped in arbitrarily nested arrays and jsx fragments.

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <>
    {[
      "Hello World!",
      <div />,
    ]}
  </>,
);

Jsx fragments return their children as is.

<>Hello World!</>; // => string
<>foo{42}</>; // => ["foo", 42]

Namespaces

By default, elements are created as HTML elements. This works fine for most cases, but requires some extra work to create SVG or MathML elements.

The namespace URI for new elements can be set via contexts.

import { mount, Inject, XMLNS, SVG } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <div>
    <Inject key={XMLNS} value={SVG}>
      {() => <svg version="1.1" viewBox="0 0 100 100">...</svg>}
    </Inject>
  </div>,
);

Components

In gluon, components are functions that return content and take arbitrary inputs. When used with JSX syntax, props are passed as the first argument.

import { mount, Signal } from "@osjwnpm/magnam-at-iusto";

function TextInput(props: {
  text: Signal<string>,
}) {
  return <input
    type="text"
    value={props.text}
    $input={event => {
      props.text.value = event.target.value;
    }}
  />;
}

const text = sig("");
mount(
  document.body,
  <>
    <TextInput text={text} />
    You typed <b>{text}</b>.
  </>,
);

By default, properties are non-reactive. To accept reactive properties, you can use expressions. To map an expression value, use the map function, to access an expression value at any time use the get function:

import { mount, get, Expression } from "@osjwnpm/magnam-at-iusto";

import classes from "./example.module.css";

function Hint(props: {
  variant: Expression<"error" | "info">,
  children: unknown,
}) {
  console.log("Initial value:", get(props.variant));

  return <div class={map(props.variant, variant => classes[variant])}>
    {props.children}
  </div>;
}

mount(
  document.body,
  <>
    <Hint variant="error">Hello World!</Hint>
    <Hint variant={() => "info"}>Hello World!</Hint>
    <Hint variant={someSignal}>Hello World!</Hint>
  </>,
);

Children

Component children are always passed via the children property.

To accept arbitrary content with it's lifecycle bound to the parent, you can use the unknown type:

import { mount } from "@osjwnpm/magnam-at-iusto";

function TextBlock(props: { children?: unknown }) {
  return <div>
    {props.children}
  </div>;
}

mount(
  document.body,
  <TextBlock>
    Hello World!
  </TextBlock>
);

You can also use arbitrary functions to render children multiple times or to pass values:

import { mount, For } from "@osjwnpm/magnam-at-iusto";

function ForEachNote(props: { children: (note: string) => unknown }) {
  return <ul>
    <For each={someNotes}>
      {note => <li>
        {props.children(note)}
      </li>}
    </For>
  </ul>;
}

mount(
  document.body,
  <ForEachNote>
    {note => <div>
      <h2>{note.title}</h2>
      <p>{note.content}</p>
    </div>}
  </ForEachNote>
);

Reactivity

Reactivity in gluon is entirely based on signals and expressions.

Signals

Signals represent values that change over time. They keep track of their dependants when accessed and notify them when updated.

import { sig } from "@osjwnpm/magnam-at-iusto";

// Create a signal with an initial value:
const count = sig(0);

// "value" can be used to access and update the value:
count.value++;
console.log(count.value); // 1

// Signals can contain any arbitrary value:
const items = sig<string[]>([]);

// "update" allows modifying the inner value in place:
items.update(items => {
  items.push("foo");
});

Expressions

Expressions can be static values, signals or functions to access or compute a value. Expressions can be watched manually or used as attributes and element content to render text.

import { mount, sig, watch } from "@osjwnpm/magnam-at-iusto";

// Create a signal:
const count = sig(0);

// Render a button and text that displays the count:
mount(
  document.body,
  <>
    <button $click={() => { count.value++ }}>Click me!</button>
    Clicked {count} times!
  </>
);

// Watch the count manually:
watch(count, count => {
  console.log(`Clicked ${count} times!`);
});

watch(() => count.value, count => {
  console.log(`Clicked ${count} times!`);
});

Note, that static values will not be reactive, even if they originate from a signal:

import { sig } from "@osjwnpm/magnam-at-iusto";

const count = sig(0);

watch(count.value, count => {
  // This is only called once, even if count is updated:
  console.log(`Clicked ${count} times!`);
});

The watchUpdates function can be used as an alternative, that returns the initial value instead of immediately calling a function with it:

import { sig, watchUpdates } from "@osjwnpm/magnam-at-iusto";

const count = sig(0);

const initialValue = watchUpdates(count, count => {
  console.log(`Count updated: ${count}`);
});

console.log("Initial count:", initialValue);

The effect function can be used when separation of expressions and side effects isn't desired:

import { effect } from "@osjwnpm/magnam-at-iusto";

const count = sig(0);

effect(() => {
  console.log(count.value);
});

Conversion

Sometimes it can be useful to convert user inputs in some way, e.g. trimming whitespace or parsing a number.

The example below shows how signals can be converted while allowing data flow in both directions. Things like the trim function below are easy to reuse and compose with other behaviors.

import { sig, Signal, watchUpdates, mount } from "@osjwnpm/magnam-at-iusto";

export function trim(source: Signal<string>): Signal<string> {
  const input = sig(source.value);

  // Write the trimmed value back into source when the input is updated:
  watchUpdates(input, value => {
    source.value = value.trim();
  });

  // Write the source value into the input if it doesn't match the trimmed input:
  watchUpdates(source, value => {
    if (value !== input.value.trim()) {
      input.value = value;
    }
  });

  return input;
}

// Create the source signal:
// This will contain the trimmed version of the raw input value.
const text = sig("Hello World!");

// Basic text input component that uses a signal for the value:
function TextInput(props: { value: Signal<string> }) {
  return <input
    prop:value={props.value}
    $input={event => {
      props.value.value = event.target.value;
    }}
  />;
}

mount(
  document.body,
  <TextInput value={trim(text)} />
);

Lifecycle

In gluon, teardown hooks are the only lifecycle primitive. They can be used to run logic, when the context, they have been registered in is disposed.

import { capture, teardown } from "@osjwnpm/magnam-at-iusto";

// Capture teardown hooks during a function call:
const dispose = capture(() => {
  // Register a teardown hook:
  teardown(() => {
    console.log("Cleanup things...");
  });
});

// Call captured teardown hooks:
dispose();

Teardown hooks can be used in watch and view callbacks.

import { mount, sig, IterUnique, teardown } from "@osjwnpm/magnam-at-iusto";

const items = sig(["foo", "bar", "baz"]);

mount(
  document.body,
  <IterUnique each={items}>
    {item => {
      console.log("Rendering:", item);
      teardown(() => {
        console.log("Removing:", item);
      });
      return <li>{item}</li>;
    }}
  </IterUnique>
);

Context

Contexts can be used to pass key-value pairs along the call stack without requiring intermediaries to know about them.

import { inject, extract } from "@osjwnpm/magnam-at-iusto";

// Inject a key-value pair:
inject("message", "Hello World!", () => {
  // Extract a value by key:
  console.log("Message:", extract("message"));
});

Contexts automatically work with all synchronous code and all gluon APIs:

import { mount, When, Inject, extract } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <Inject key="message" value="Hello World!">
    {() => <When value={someSignal}>
      {() => <h1>{extract("message")}</h1>}
    </When>}
  </Inject>
);

Typed Keys

Context values are typed as unknown by default.

To use typed context values, you can use symbols in combination with the ContextKey type as keys:

import {} from "@osjwnpm/magnam-at-iusto";

const MESSAGE = Symbol("message") as ContextKey<string>;

inject(MESSAGE, "Hello World!", () => {
  extract(MESSAGE); // Type: string | undefined
});

// This is a compiler error:
inject(MESSAGE, 42, () => { ... });

Async Code

To make contexts work with other asynchronous code, you can manually run functions in a different context:

import { inject, getContext, runInContext } from "@osjwnpm/magnam-at-iusto";

inject("message", "Hello World!", () => {
  const context = getContext();

  queueMicrotask(() => {
    console.log(extract("message")); // => undefined

    runInContext(context, () => {
      console.log(extract("message")); // => "Hello World!"
    });
  });
});

Performance

Update Batching

Signal updates are processed immediately by default. This can lead to update overhead when updating many signals at once that are all used in the same expression:

import { sig, watch } from "@osjwnpm/magnam-at-iusto";

const a = sig(0);
const b = sig(42);
const c = sig(7);

watch(() => a.value + b.value + c.value, sum => {
  console.log(sum);
});

// The watch callback is called for each individual update:
a.value++;
b.value++;
c.value++;

To avoid this, signal updates can be deferred, deduplicated and processed immediately after all signals have been updated:

import { sig, watch, batch } from "@osjwnpm/magnam-at-iusto";

const a = sig(0);
const b = sig(42);
const c = sig(7);

watch(() => a.value + b.value + c.value, sum => {
  console.log(sum);
});

// Defer and update all signals at once:
batch(() => {
  a.value++;
  b.value++;
  c.value++;
});

Lazy Expressions

When an expression is used in multiple places, it is also evaluated multiple times for each signal update. This can lead to performance problems when the expression is computationally expensive.

import { sig, watch } from "@osjwnpm/magnam-at-iusto";

const input = sig(0);

const expression = () => expensiveComputation(input.value);

// "expensiveComputation" runs multiple times for the same input:
watch(expression, () => { ... });
watch(expression, () => { ... });

To avoid this, expressions can be wrapped using lazy:

import { sig, watch, lazy } from "@osjwnpm/magnam-at-iusto";

const input = sig(0);

// "expensiveComputation" dosn't run immediately:
const expression = lazy(() => expensiveComputation(input.value));

// "expensiveComputation" runs only when any input was updated:
watch(expression, () => { ... });
watch(expression, () => { ... });

Memos

The memo utility is similar to lazy expressions, but the wrapped expression is evaulated even if the output is not used.

import { sig, watch, memo } from "@osjwnpm/magnam-at-iusto";

const input = sig(0);

// "expensiveComputation" runs once immediately:
const expression = memo(() => expensiveComputation(input.value));

// "expensiveComputation" runs only when any input was updated:
watch(expression, () => { ... });
watch(expression, () => { ... });

Async Utilities

Tasks

The task system in gluon keeps track of pending tasks in a specific context.

import { mount, Inject } from "@osjwnpm/magnam-at-iusto";
import { isPending, waitFor } from "@osjwnpm/magnam-at-iusto/async";

mount(
  document.body,
  <Inject value={new Tasks()}>
    {() => <>
      <button
        disabled={isPending}
        $click={() => {
          waitFor(new Promise(resolve => {
            setTimeout(resolve, 1000);
          }));
        }}
      >Click me!</button>
    </>}
  </Inject>
);

Async

Render content depending on an async function or promise.

import { mount } from "@osjwnpm/magnam-at-iusto";
import { Async } from "@osjwnpm/magnam-at-iusto/async";

const promise = new Promise(resolve => setTimeout(resolve, 1000));

mount(
  document.body,
  <Async source={promise} pending={() => "Pending..."} rejected={error => `Rejected: ${error}`}>
    {value => <>Resolved: {value}</>}
  </Async>,
);

To use promises returned from an expression, this can be combined with <Show>:

import { mount, When, sig } from "@osjwnpm/magnam-at-iusto";
import { Async } from "@osjwnpm/magnam-at-iusto/async";

const promise = sig(undefined);
setInterval(() => {
  promise.value = new Promise(resolve => setTimeout(resolve, 1000));
}, 3000);

mount(
  document.body,
  <When value={promise}>
    {promise => <Async source={promise} pending={() => "Pending..."} rejected={error => `Rejected: ${error}`}>
      {value => <>Resolved: {value}</>}
    </Async>}
  </When>
);

Async contexts can be used to wait for all async parts of a tree to complete:

import { mount, Inject } from "@osjwnpm/magnam-at-iusto";
import { ASYNC, Async, AsyncContext } from "@osjwnpm/magnam-at-iusto/async";

// Create a new context with the current one as parent if there is one:
const context = AsyncContext.fork();

mount(
  document.body,
  <Inject key={ASYNC} value={context}>
    {() => <>
      <Async ... />
    </>}
  </Inject>
);

// Wait for all async parts to complete:
await context.complete();

Abort Controllers

Abort controllers can be used in many web APIs to abort things.

The useAbortController and useAbortSignal functions can be used to abort things when the current context is disposed e.g. when content inside a <When> component is no longer rendered.

import { useAbortSignal } from "@osjwnpm/magnam-at-iusto/async";

fetch("/info.txt", { signal: useAbortSignal() });

window.addEventListener("keydown", () => { ... }, { signal: useAbortSignal() });

Routing

Routers provide a reactive path and query parameters and allow navigating in their current context.

Currently, there is a HistoryRouter that uses the location and history API and a HashRouter that uses the location hash as the path. You can also implement custom routers by implementing the Router interface.

import { mount, Inject } from "@osjwnpm/magnam-at-iusto";
import { ROUTER, HistoryRouter } from "@osjwnpm/magnam-at-iusto/router";

mount(
  document.body,
  <Inject key={ROUTER} value={new HistoryRouter()}>
    {() => <>
      Everything in here has access to the history router.
    </>}
  </Inject>
);

The Routes component can be used to render content based on the current path.

import { mount, Inject } from "@osjwnpm/magnam-at-iusto";
import { ROUTER, HistoryRouter, Routes } from "@osjwnpm/magnam-at-iusto/router";

mount(
  document.body,
  <Inject key={ROUTER} value={new HistoryRouter()}>
    {() => <>
      <Routes routes={[
        { match: "/", content: () => "Home" },
        { match: "/foo", content: ExamplePage },
        { content: () => "Not found" },
      ]} />
    </>}
  </Inject>
);

function ExamplePage() {
  return <>Example</>;
}

Route Matching

Routes are matched against the normalized path in the order in which they are specified.

Strings match exactly that path and all sub paths if they end with a slash:

[
  // Matches only "/foo":
  { match: "/foo", ... },
  // Matches "/foo", "/foo/bar" etc.
  { match: "/foo/", ... },
]

Regular expressions are tested against the normalized path:

[
  // Matches only "/foo":
  { match: /^\/foo$/, ... },

  // Matches "/user/123":
  {
    match: /^\/user\/(\d+)$/,
    content: ({ params }) => {
      // The match is passed via the "params" property:
      return <>User id: {params[1]}</>;
    },
  },
]

Gluon itself doesn't provide any custom syntax for dynamic routes, but you can use a package like path-to-regexp if you need to:

import { pathToRegexp } from "path-to-regexp";

[
  { match: pathToRegexp("/user/:id"), ... }
]

Functions can return an object with the normalized matched path and optional parameters to indicate a match:

import { normalize } from "@osjwnpm/magnam-at-iusto/router";

[
  {
    match: path => {
      if (/\/foo(\/|$)/.test(path)) {
        return {
          path: normalize(path.slice(4)),
          params: 42,
        };
      }
    },
    content: ({ params }) => {
      return <>{params}</>;
    },
  }
]

Path Normalization

Paths are normalized, so that non-empty paths always start with a slash and the root path is represented as an empty string.

Navigation

The router in the current context can be used for navigation.

Routers implement a push function for regular navigation and a replace function for replacing the current path if possible.

import { extract } from "@osjwnpm/magnam-at-iusto";
import { ROUTER } from "@osjwnpm/magnam-at-iusto/router";

function ExamplePage() {
  const router = extract(ROUTER).root;
  return <button $click={() => {
    router.push("/some-path");
  }}>Navigate</button>;
}

Note, that the router instance is replaced with a child router inside of routed content. In this case, the root property always provides access to the history router from above.

Nested Routing

Routes can be arbitrarily nested with content in between.

The example below renders text for the paths /, /foo/bar, /foo/baz

import { mount, Inject, extract } from "@osjwnpm/magnam-at-iusto";
import { ROUTER, HistoryRouter, Routes } from "@osjwnpm/magnam-at-iusto/router";

mount(
  document.body,
  <Inject key={ROUTER} value={new HistoryRouter()}>
    {() => <>
      <Routes routes={[
        { match: "/", content: () => "Home" },
        { match: "/foo/", content: () => {
          const innerRouter = extract(ROUTER);
          return <Routes routes={[
            { match: "/bar", content: () => "Bar" },
            { match: "/baz", content: () => "Baz" },
          ]} />;
        } },
      ]} />
    </>}
  </Inject>
);

The router instance is replaced with a child router inside of routed content which only exposes the unmatched rest path and navigates within the matched path. In the example above, the innerRouter navigates within /foo:

// Navigates to /foo/bar:
innerRouter.push("/bar");

// To navigate globally, use the root router instead:
innerRouter.root.push("/foo/bar");

Web Components

Gluon supports using web components just like any other native element.

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <some-web-component />
);

To implement a web component, you can extend the GluonElement class which takes care of creating a shadow root and renders content when the element is connected to the document:

import { GluonElement } from "@osjwnpm/magnam-at-iusto/element";

class ExampleComponent extends GluonElement {
  render() {
    return <h1>Hello World!</h1>;
  }
}

customElements.define("example-component", ExampleComponent);

Reflecting Attributes

The reflect method can be used to get a signal that reflects an attribute value.

import { GluonElement, attribute } from "@osjwnpm/magnam-at-iusto/element";

class ExampleCounter extends GluonElement {
  // Allow this component to detect changes to the "count" attribute:
  static observedAttributes = ["count"];

  // Create a signal that reflects the "count" attribute:
  #count = this.reflect("count");

  render() {
    return <button $click={() => {
      const newCount = Number(this.#count) + 1;

      // Updating the signal will also update the "count" attribute:
      this.#count.value = newCount;

      // Dispatch an event to notify users of your web component:
      this.dispatchEvent(new CustomEvent("count-changed", { detail: newCount }));
    }}>
      Clicked {this.#count} times!
    </button>;
  }

  // Optionally, you can implement property accessors:
  get count() {
    return Number(this.#count.value);
  }
  set count(value: number) {
    this.#count.value = String(value);
  }
}

customElements.define("example-counter", ExampleComponent);

Manual Implementation

Due to it's simple lifecycle system, you can also implement web components manually:

import { mount, capture, teardown, TeardownHook } from "@osjwnpm/magnam-at-iusto";

class ExampleComponent extends HTMLElement {
  #dispose: TeardownHook;

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.#dispose = capture(() => {
      // Create and append content to the shadow root:
      const view = mount(
        this.shadowRoot,
        <h1>Hello World!</h1>,
      );

      // Remove content from the shadow root when disposed:
      teardown(() => view.detach());
    });
  }

  disconnectedCallback() {
    // Run teardown hooks:
    this.#dispose?.();
    this.#dispose = undefined;
  }
}

State Management

The state management API provides a way to create deep reactive wrappers for arbitrary objects.

The wrap function creates a deep reactive wrapper:

import { mount } from "@osjwnpm/magnam-at-iusto";
import { wrap } from "@osjwnpm/magnam-at-iusto/store";

const state = wrap({
  message: "Hello World!",
});

mount(
  document.body,
  <h1>{() => state.message}</h1>
);

By default, Arrays, Maps, Sets and Objects without or with the Object constructor are reactive. Anything else is returned as is.

Updates

To update a reactive object, you can directly modify the wrapper.

const todos = wrap([
  { name: "Foo", done: false },
  { name: "Bar", done: false },
]);

todos[1].done = true;
todos.push({ name: "Baz", done: true });

Note, that every individual update is processed immediately. To prevent this, you can use batches:

import { batch } from "@osjwnpm/magnam-at-iusto";

batch(() => {
  todos[1].done = true;
  todos.push({ name: "Baz", done: true });
});

Classes

By default, arbitrary class instances are not reactive unless you specify, how to wrap them:

import { wrapInstancesOf } from "@osjwnpm/magnam-at-iusto";

class Example {
  static {
    // Wrap instances of "Example" in the same way, objects are wrapped:
    wrapInstancesOf(this);

    // Or implement custom behavior:
    wrapInstancesOf(this, target => {
      return new Proxy(target, ...);
    });
  }
}

Private Fields

Private fields are not reactive. Also, you need to ensure they are accessed through the original object instead of reactive wrappers by using unwrap(..).

class Example {
  #count = 0;

  thisWorks() {
    // "unwrap" always returns the original object
    // or the value itself if it isn't a wrapper:
    unwrap(this).#count++;
  }

  thisFails() {
    // This will fail, since "this" refers to the
    // reactive wrapper instead of the original object:
    this.#count++;
  }
}

const example = wrap(new Example());
example.thisWorks();
example.thisFails();

Troubleshooting

This section shows some common pitfalls and how to deal with them.

Missing Context Values

Context Key Typos

Ensure that the key argument is the same everywhere.

inject("message", "Hello World!", () => {
  // There is a typo here:
  extract("nessage");
});

To avoid this, you can use typed context keys:

const MESSAGE = Symbol.for("example-message") as ContextKey<string>;

inject(MESSAGE, "Hello World!", () => {
  // This typo is now a compiler error:
  extract(NESSAGE);
});

Extract Running Too Late

extract must be called synchronously while the callback passed to inject or deriveContext is running.

inject(MESSAGE, "Hello World!", () => {
  queueMicrotask(() => {
    // This runs after the inject call has already ended:
    extract(MESSAGE); // undefined
  });
});

To solve this, you can forward the context as follows:

inject(MESSAGE, "Hello World!", () => {

  // Bind the current context to your callback:
  queueMicrotask(wrapContext(() => {
    extract(MESSAGE); // "Hello World!"
  }));

  // Or manually pass the context to somewhere else:
  const context = getContext();
  queueMicrotask(() => {
    runInContext(context, () => {
      extract(MESSAGE); // "Hello World!"
    });
  });

});

Extract Running Too Early

When using deriveContext, the context must be modified before extract is called.

deriveContext(ctx => {
  // This doesn't work:
  extract(MESSAGE); // undefined

  ctx.set(MESSAGE, "Hello World!");

  // This works:
  extract(MESSAGE); // "Hello World!"
});

Reactivity Not Working

For things to get updated or re-rendered, the following needs to be true:

  • The value in a signal must be replaced, or the signal must notify dependants using notify or update.
  • The place where the value is used must be able to access the signal by calling a function.

Deep Updates

Signals don't automatically detect when values are deeply changed. They only detect when values are entirely replaced.

const counter = sig({ count: 0 });
// This will not trigger any updates:
counter.value.count++;

When possible, you should wrap the inner values into signals:

const counter = { count: sig(0) };
// Signals can also be deeply nested:
const counter = sig({ count: sig(0) });

When this isn't possible, you can use one of the following options:

// Use the update function:
counter.update(value => {
  value.count++;
});

// Replace the entire value:
counter.value = { count: 1 };

// Manually notify dependants:
counter.value.count++;
counter.notify();

Static Values

The value of signals or expressions can always be accessed in a non reactive ways:

const count = sig(0);

// This isn't reactive:
mount(document.body, <>{count.value}</>);
mount(document.body, <>{get(count)}</>);

For signal accesses to be reactive, they need to be done in a function call:

// This is now reactive:
mount(document.body, <>{() => count.value}</>);
mount(document.body, <>{() => get(count)}</>);

// Using the signal itself is also reactive:
mount(document.body, <>{count}</>);

This is also true for every other API that uses expressions. This way you always have the option to make something reactive or static.

Strict Equality

By default, signals don't notify dependants after replacing the value if it's strictly equal to the previous one.

const count = sig(0);
// This will not trigger any updates:
count.value = 0;

To force updates, you can use one of the following options:

// Manually notify dependants:
count.notify();

// Disable the default equality check:
const count = sig(0, false);

// Use a custom equality check:
const count = sig(0, (previous, current) => false);

Testing

Testing gluon based applications is usually very simple because all of it's signal based rendering is synchronous. E.g. when updating a signal, all resulting changes are reflected in the DOM immediately:

const count = sig(7);
const element = <div>Current count: {count}</div> as HTMLDivElement;
assert(element.innerText === "Current count: 7");
count.value = 42;
assert(element.innerText === "Current count: 42");

Synchronous Tests

Gluon provides a lightweight wrapper for running small synchronous tests that provides a context and takes care of calling teardown hooks after the test.

import { runTest, querySelector } from "@osjwnpm/magnam-at-iusto/test";

runTest(ctx => {
  const count = sig(0);
  const view = mount(
    document.body,
    <button $click={() => { count.value++; }}>Click me!</button>,
  );
  querySelector(view, "button")?.click();
  assert(count.value === 1);
});

Asynchronous Tests

Almost all gluon APIs rely on the synchronous call stack. E.g. extracting values from the current context will not work after awaiting something:

inject("foo", "bar", async () => {
  extract("foo"); // => "bar"
  await something();
  extract("foo"); // => undefined
});

There is a wrapper for async tests that allows you to run small synchronous parts of your test with a shared context, an async context. After the test, this will run teardown hooks registered during "use(..)" calls and wait for any pending tasks tracked in the async context.

The example below shows a test that asserts that asynchronously loaded content is displayed correctly:

import { runAsyncTest, querySelector } from "@osjwnpm/magnam-at-iusto/test";

await runAsyncTest(async ({ ctx, asyncCtx, use }) => {
  const view = use(() => {
    return mount(
      document.body,
      <Async source={async () => {
        await something();
        return "Hello World!";
      }}>
        {content => <div class="page">
          {content}
        </div>}
      </Async>
    );
  });

  // Wait for the "<Async>" component to resolve:
  await asyncCtx.complete();

  const page = querySelector<HTMLElement>(view, ".page");
  assert(page !== null);
  assert(page.innerText === "Hello World!");
});

Waiting For Expressions

You can watch arbitrary expressions using the watchFor function.

import { sig, watchFor, isPending } from "@osjwnpm/magnam-at-iusto";

// Wait for a specific signal value:
const count = sig(0);
doSomethingAsyncWithCount();
await watchFor(() => count.value > 7);

// Wait with a timeout:
await watchFor(() => count.value > 7, 500);

// Wait for pending user tasks:
await watchFor(() => !isPending());

Leak Detection

The lifecycle API silently discards teardown hooks outside of capture calls. This can be a valid use case, for instance when rendering your application until the browser closes or when intentionally leaking teardown hooks using uncapture.

However, this can result in accidental memory leaks when registering teardown hooks in async code:

const stop = capture(async () => {
  await something();
  const interval = setInterval(() => console.log("ping!"), 1000);
  // "clearInterval" will never be called:
  teardown(() => clearInterval(interval));
});

stop();

To catch these cases, you can use the onTeardownLeak function once before running all of your tests:

import { onTeardownLeak } from "@osjwnpm/magnam-at-iusto/test";

onTeardownLeak(hook => {
  // "hook" is the teardown hook that is being registered.
  console.trace("Leaked teardown hook:", hook);

  // Or throw an error from within the **teardown** call:
  throw new Error("Teardown hook was not captured.");
});

// This will now call the code above:
teardown(() => {});

// This will NOT call the code above, as using **uncapture** is very likely intentional:
uncapture(() => teardown(() => {}));

Concurrency

It is generally possible to run tests for gluon based applications concurrently. However, using APIs that may interfere with each other such as Element.focus can result in flaky tests. To solve this you can use the exclusive function to run code in a globally shared queue for a specific purpose:

import { runAsyncTest, exclusive } from "@osjwnpm/magnam-at-iusto/test";

const FOCUS_ACTIONS = Symbol("focus actions");

await exclusive(FOCUS_ACTIONS, async () => {
  someInput.focus();
  await somethingElse();
  assert(document.activeElement === someInput);
});

Using symbols as keys that are in some common place in your test setup is recommended as it prevents any typos in the key, but you can also use anything alse that can be a Map key.


Shared Globals & Compatibility

Gluon's signal, context and lifecycle APIs are based on globals and the synchronous call stack.

Example: To capture lifecycle teardown hooks, the capture function pushes a new array onto a global stack and then runs a synchronous function which may add teardown hooks to that array. After this the array is removed from the stack and will contain all the registered teardown hooks which can be called later.

From gluon v5.2 and upwards, these globals are shared between different versions of gluon that run on the same thread. Additionally, the instanceof operator will also work with View and Signal instances from other versions.

This makes it possible to use newer versions of gluon without being forced to update dependencies. For instance, when using gluon v6, you could still use a UI library based on gluon v5.2 just fine.


Security

As with any other rendering library, there are several ways to introduce severe security vulnerabilities into a gluon based application by directly allowing user input in specific places.

Assuming that userInput is arbitrary user input, the examples below can lead to severe security vulnerabilities:

// The "prop:innerHTML" attribute can be used to directly render HTML:
<div prop:innerHTML={userInput} />;
<div prop:innerHTML={"<script>alert(location.origin)</script>"} />;

// Any native event attributes can be used to run javascript when dispatched:
<div onclick={userInput} />;
<div onclick="alert(location.origin)" />;

// Because of the above, user controlled attribute names are at least problematic:
<div {...{ [userInput]: somethingElse }} />;
<div {...{ ["prop:innerHTML"]: "<script>alert(location.origin)</script>" }} />;

In summary, you should never use arbitrary user input as:

  • prop:innerHTML attribute value
  • on... attribute value
  • attribute name

In contrast, user input used in other attributes and as content is perfectly fine as shown in the example below:

import { mount } from "@osjwnpm/magnam-at-iusto";

mount(
  document.body,
  <div title="alert(location.origin)">
    {"<script>alert(location.origin)</script>"}
  </div>
);

Package Sidebar

Install

npm i @osjwnpm/magnam-at-iusto

Weekly Downloads

1

Version

1.0.0

License

MIT

Unpacked Size

58.7 kB

Total Files

10

Last publish

Collaborators

  • lequoc12780