├── .github
└── workflows
│ └── ci.yaml
├── .gitignore
├── .nvmrc
├── .vscode
└── settings.json
├── README.md
├── cspell.json
├── jasmine-runner.mjs
├── package-lock.json
├── package.json
├── src
├── attribute-accessor.test.html
├── attribute-accessor.test.ts
├── attribute-accessor.ts
├── base-component.test.html
├── base-component.test.ts
├── base-component.ts
├── component-accessor.test.html
├── component-accessor.test.ts
├── component-accessor.ts
├── connectable.test.html
├── connectable.test.ts
├── connectable.ts
├── custom-elements.test.html
├── custom-elements.test.ts
├── custom-elements.ts
├── dehydrated.test.html
├── dehydrated.test.ts
├── dehydrated.ts
├── demo
│ ├── attrs
│ │ ├── index.html
│ │ ├── read-attr.test.html
│ │ ├── read-attr.test.ts
│ │ └── read-attr.ts
│ ├── composition
│ │ ├── composed-counter.test.html
│ │ ├── composed-counter.test.ts
│ │ ├── composed-counter.ts
│ │ └── index.html
│ ├── counter
│ │ ├── auto-counter.test.html
│ │ ├── auto-counter.test.ts
│ │ ├── auto-counter.ts
│ │ ├── bind-counter.test.html
│ │ ├── bind-counter.test.ts
│ │ ├── bind-counter.ts
│ │ ├── button-counter.test.html
│ │ ├── button-counter.test.ts
│ │ ├── button-counter.ts
│ │ └── index.html
│ ├── hello-world
│ │ ├── hello-world.test.html
│ │ ├── hello-world.test.ts
│ │ ├── hello-world.ts
│ │ └── index.html
│ ├── hooks
│ │ ├── custom-hook.test.html
│ │ ├── custom-hook.test.ts
│ │ ├── custom-hook.ts
│ │ └── index.html
│ ├── hydration
│ │ ├── deferred-comp.test.html
│ │ ├── deferred-comp.test.ts
│ │ ├── deferred-comp.ts
│ │ ├── deferred-composition-child.ts
│ │ ├── deferred-composition.test.html
│ │ ├── deferred-composition.test.ts
│ │ ├── deferred-composition.ts
│ │ ├── index.html
│ │ ├── is-land.test.html
│ │ └── is-land.test.ts
│ ├── index.html
│ ├── reactivity
│ │ ├── cached-value.test.html
│ │ ├── cached-value.test.ts
│ │ ├── cached-value.ts
│ │ ├── computed-value.test.html
│ │ ├── computed-value.test.ts
│ │ ├── computed-value.ts
│ │ ├── index.html
│ │ ├── signal-effect.test.html
│ │ ├── signal-effect.test.ts
│ │ └── signal-effect.ts
│ └── shadow
│ │ ├── closed-shadow.test.html
│ │ ├── closed-shadow.test.ts
│ │ ├── closed-shadow.ts
│ │ ├── index.html
│ │ ├── open-shadow.test.html
│ │ ├── open-shadow.test.ts
│ │ └── open-shadow.ts
├── element-accessor.test.html
├── element-accessor.test.ts
├── element-accessor.ts
├── hydration.test.html
├── hydration.test.ts
├── hydration.ts
├── hydroactive-component.test.html
├── hydroactive-component.test.ts
├── hydroactive-component.ts
├── index.ts
├── query-root.test.html
├── query-root.test.ts
├── query-root.ts
├── query.test.html
├── query.test.ts
├── query.ts
├── queryable.test.html
├── queryable.test.ts
├── queryable.ts
├── serializer-tokens.test.html
├── serializer-tokens.test.ts
├── serializer-tokens.ts
├── serializers.ts
├── serializers
│ ├── index.ts
│ ├── json-serializer.test.html
│ ├── json-serializer.test.ts
│ ├── json-serializer.ts
│ ├── primitive-serializers.test.html
│ ├── primitive-serializers.test.ts
│ ├── primitive-serializers.ts
│ └── serializer.ts
├── signal-accessors.test.html
├── signal-accessors.test.ts
├── signal-accessors.ts
├── signal-component-accessor.test.html
├── signal-component-accessor.test.ts
├── signal-component-accessor.ts
├── signal-component.test.html
├── signal-component.test.ts
├── signal-component.ts
├── signals.ts
├── signals
│ ├── cached.test.html
│ ├── cached.test.ts
│ ├── cached.ts
│ ├── effect.test.html
│ ├── effect.test.ts
│ ├── effect.ts
│ ├── graph.test.html
│ ├── graph.test.ts
│ ├── graph.ts
│ ├── index.ts
│ ├── reactive-root.test.html
│ ├── reactive-root.test.ts
│ ├── reactive-root.ts
│ ├── schedulers
│ │ ├── macrotask-scheduler.test.html
│ │ ├── macrotask-scheduler.test.ts
│ │ ├── macrotask-scheduler.ts
│ │ ├── scheduler.test.html
│ │ ├── scheduler.test.ts
│ │ ├── scheduler.ts
│ │ ├── stability-tracker.test.html
│ │ ├── stability-tracker.test.ts
│ │ ├── stability-tracker.ts
│ │ ├── sync-scheduler.test.html
│ │ ├── sync-scheduler.test.ts
│ │ ├── sync-scheduler.ts
│ │ ├── test-scheduler.test.html
│ │ ├── test-scheduler.test.ts
│ │ ├── test-scheduler.ts
│ │ ├── ui-scheduler.test.html
│ │ ├── ui-scheduler.test.ts
│ │ └── ui-scheduler.ts
│ ├── signal.test.html
│ ├── signal.test.ts
│ ├── signal.ts
│ └── types.ts
├── testing.ts
├── testing
│ ├── html-parser.test.html
│ ├── html-parser.test.ts
│ ├── html-parser.ts
│ ├── index.ts
│ ├── noop-component.ts
│ ├── test-cases.test.html
│ ├── test-cases.test.ts
│ ├── test-cases.ts
│ ├── timing.test.html
│ ├── timing.test.ts
│ └── timing.ts
└── utils
│ ├── casing.test.html
│ ├── casing.test.ts
│ ├── casing.ts
│ ├── on-demand-definitions.html
│ ├── on-demand-definitions.test.ts
│ ├── on-demand-definitions.ts
│ ├── types.test.html
│ ├── types.test.ts
│ └── types.ts
├── tsconfig.base.json
├── tsconfig.demo.json
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.test.json
└── web-test-runner.config.mjs
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | # Run CI for the `ci` branch. This branch isn't special in any way, it just
9 | # does not enforce linear history, so edits to the CI workflow can be tested
10 | # there where commits can still be amended before they are immutably pushed
11 | # to main.
12 | - ci
13 |
14 | pull_request:
15 | branches:
16 | - main
17 |
18 | jobs:
19 | test:
20 | runs-on: ubuntu-20.04
21 |
22 | steps:
23 |
24 | # Checkout the repository.
25 | - uses: actions/checkout@v3
26 |
27 | # Install Node.js.
28 | - uses: actions/setup-node@v4
29 | with:
30 | node-version-file: '.nvmrc'
31 |
32 | # Install dependencies.
33 | - name: Install
34 | run: npm ci
35 |
36 | # Run tests.
37 | - name: Test
38 | run: npm test
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20.10.0
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 2,
3 | "typescript.preferences.importModuleSpecifierEnding": "js",
4 | "typescript.preferences.quoteStyle": "single",
5 | "typescript.tsdk": "node_modules/typescript/lib",
6 | "[html]": {
7 | "editor.tabSize": 4
8 | },
9 | "cSpell.words": [
10 | "Macrotask",
11 | "serializables"
12 | ],
13 | }
14 |
--------------------------------------------------------------------------------
/cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2",
3 | "ignorePaths": [],
4 | "dictionaryDefinitions": [],
5 | "dictionaries": [],
6 | "words": [
7 | "Clazz",
8 | "Defineable",
9 | "hydroactive",
10 | "prerendered",
11 | "templating",
12 | "webcomponents",
13 | "shakability"
14 | ],
15 | "ignoreWords": [],
16 | "import": []
17 | }
18 |
--------------------------------------------------------------------------------
/jasmine-runner.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Exposes a `run` function which runs a Jasmine test suite for the given entry point.
3 | *
4 | * Forked from https://github.com/blueprintui/web-test-runner-jasmine/blob/d07dad01e9e287ea96c41c433c6f787f6170566a/src/index.ts.
5 | */
6 |
7 | import {
8 | getConfig,
9 | sessionFailed,
10 | sessionFinished,
11 | sessionStarted,
12 | } from '@web/test-runner-core/browser/session.js';
13 |
14 | // Initialize Jasmine on the page.
15 | const jasmine = jasmineRequire.core(jasmineRequire);
16 | const jasmineGlobal = jasmine.getGlobal();
17 | jasmineGlobal.jasmine = jasmine;
18 |
19 | const jasmineEnv = jasmine.getEnv();
20 |
21 | Object.assign(jasmineGlobal, jasmineRequire.interface(jasmine, jasmineEnv));
22 |
23 | // Web Test Runner uses a different HTML page for every test, so we only get
24 | // one `testFile` for the single `*.js` file we need to execute.
25 | const { testFile: htmlFile, testFrameworkConfig } = await getConfig();
26 | const config = {
27 | defaultTimeoutInterval: 60_000,
28 | ...(testFrameworkConfig ?? {}),
29 | };
30 | const testFile = htmlFile.replace(/\.html(?=\?|$)/, '.js');
31 |
32 | jasmine.DEFAULT_TIMEOUT_INTERVAL = config.defaultTimeoutInterval;
33 | jasmineEnv.configure(config);
34 |
35 | const allSpecs = [];
36 | const failedSpecs = [];
37 |
38 | jasmineEnv.addReporter({
39 | specDone(result) {
40 | const expectations = [
41 | ...result.passedExpectations,
42 | ...result.failedExpectations,
43 | ];
44 | allSpecs.push(...expectations.map((e) => ({
45 | name: e.fullName,
46 | passed: e.passed,
47 | })));
48 |
49 | for (const e of result.failedExpectations) {
50 | const message = `${result.fullName}\n${e.message}\n${e.stack}`;
51 | console.error(message);
52 | failedSpecs.push({
53 | message,
54 | name: e.fullName,
55 | stack: e.stack,
56 | expected: e.expected,
57 | actual: e.actual,
58 | });
59 | }
60 |
61 | if (result.status === 'failed' && expectations.length === 0
62 | && config.failSpecWithNoExpectations) {
63 | console.error(`Spec ran no expectations - "${result.fullName}"`);
64 | }
65 | },
66 |
67 | async jasmineDone(result) {
68 | console.log(`Tests ${result.overallStatus}!`);
69 |
70 | // Tests may be "incomplete" if no spec is found (no `it`) or a test was
71 | // focused (`fit`).
72 | if (result.incompleteReason) {
73 | failedSpecs.push({
74 | message: result.incompleteReason,
75 | });
76 | }
77 |
78 | await sessionFinished({
79 | passed: result.overallStatus === 'passed',
80 | errors: failedSpecs,
81 | testResults: {
82 | name: '',
83 | suites: [],
84 | tests: allSpecs,
85 | },
86 | });
87 | },
88 | });
89 |
90 | await sessionStarted();
91 |
92 | // Load the test file and evaluate it.
93 | try {
94 | await import(new URL(testFile, document.baseURI).href);
95 |
96 | // Execute the test functions.
97 | jasmineEnv.execute();
98 | } catch (err) {
99 | console.error(err);
100 | await sessionFailed(err);
101 | }
102 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hydroactive",
3 | "version": "0.1.7",
4 | "type": "module",
5 | "exports": {
6 | ".": "./src/index.js",
7 | "./serializers.js": "./src/serializers.js",
8 | "./signal-accessors.js": "./src/signal-accessors.js",
9 | "./signals.js": "./src/signals.js",
10 | "./testing.js": "./src/testing.js"
11 | },
12 | "sideEffects": false,
13 | "scripts": {
14 | "start": "npm run -s demo",
15 | "build": "npm run -s clean && npm run -s build-lib",
16 | "build-lib": "tsc --project tsconfig.lib.json && npm run -s build-package",
17 | "build-package": "cp package.json README.md dist/",
18 | "build-tests": "npm run -s clean && npm run -s build-tests-ts && npm run -s build-tests-html && npm run -s build-package",
19 | "build-tests-ts": "tsc -p tsconfig.test.json",
20 | "build-tests-html": "(cd src/ && find . -name '*.test.html' -exec cp --parents '{}' ../dist/src/ \\;)",
21 | "demo": "npm run -s clean && npm run -s build-demo && http-server dist/src/demo/",
22 | "build-demo": "npm run -s build-demo-ts && npm run -s build-demo-html && npm run -s build-demo-link-hydroactive && npm run -s build-demo-link-node_modules",
23 | "build-demo-ts": "tsc --project tsconfig.demo.json",
24 | "build-demo-html": "mkdir -p dist/src/demo/ && (cd src/demo/ && find . -name '*.html' -exec cp --parents '{}' ../../dist/src/demo/ \\;)",
25 | "build-demo-link-hydroactive": "(cd dist/src/demo/ && ln -s ../ hydroactive)",
26 | "build-demo-link-node_modules": "(cd dist/src/demo/ && ln -s ../../../node_modules)",
27 | "test": "npm run -s clean && npm run -s build-tests && npm run -s wtr",
28 | "test-debug": "npm run -s clean && npm run -s build-tests && npm run -s wtr-debug",
29 | "wtr": "web-test-runner \"dist/**/*.test.html\" --puppeteer",
30 | "wtr-debug": "npm run -s wtr -- --manual",
31 | "clean": "rm -rf dist/"
32 | },
33 | "license": "MIT",
34 | "devDependencies": {
35 | "@11ty/is-land": "^4.0.0",
36 | "@types/jasmine": "^4.3.1",
37 | "@web/test-runner": "^0.15.3",
38 | "@web/test-runner-puppeteer": "^0.16.0",
39 | "@webcomponents/scoped-custom-element-registry": "^0.0.9",
40 | "http-server": "^14.1.1",
41 | "jasmine-core": "^4.5.0",
42 | "typescript": "^5.5.4"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/attribute-accessor.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `attribute-accessor` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/attribute-accessor.ts:
--------------------------------------------------------------------------------
1 | import { AttrSerializerToken, ResolveSerializer, resolveSerializer } from './serializer-tokens.js';
2 | import { Serialized, AttrSerializer, AttrSerializable } from './serializers.js';
3 |
4 | /**
5 | * Wraps an attribute of an element in a convenient wrapper for accessing it's
6 | * contents with a `Serializer`.
7 | */
8 | export class AttrAccessor {
9 | readonly #native: Element;
10 | readonly #name: string;
11 |
12 | private constructor(native: Element, name: string) {
13 | this.#native = native;
14 | this.#name = name;
15 | }
16 |
17 | /**
18 | * Creates a new {@link AttrAccessor}.
19 | *
20 | * @param native The native {@link Element} to wrap.
21 | * @param name The name of the attribute to wrap.
22 | * @returns An {@link AttrAccessor} wrapping the given element and attribute.
23 | */
24 | public static from(native: Element, name: string): AttrAccessor {
25 | return new AttrAccessor(native, name);
26 | }
27 |
28 | /**
29 | * Returns whether or not the attribute is currently present on the element.
30 | * If the attribute is an empty string, returns `true` (just like
31 | * {@link Element.prototype.hasAttribute}).
32 | *
33 | * @returns Whether or not the attribute is currently present on the element.
34 | */
35 | public exists(): boolean {
36 | return this.#native.hasAttribute(this.#name);
37 | }
38 |
39 | /**
40 | * Reads the underlying attribute by deserializing it with the
41 | * {@link AttrSerializer} referenced by the provided token.
42 | *
43 | * @param token A token which resolves to an {@link AttrSerializer} to
44 | * deserialize the attribute value with.
45 | * @param options Additional options.
46 | * `optional` specifies what happens when the attribute is not present. If
47 | * `optional` is `false` (default), an error is thrown. If `optional`
48 | * is `true`, then `null` is returned.
49 | * @returns The deserialized value read from the attribute. Returns `null` if
50 | * `optional` is `true` and the attribute is not present.
51 | * @throws If the attribute is not present and `optional` is `false` (default).
52 | */
53 | public read>(token: Token, options?: {
54 | optional?: false,
55 | }): Serialized,
58 | AttrSerializable
59 | >>;
60 | public read>(token: Token, options?: {
61 | optional?: boolean,
62 | }): Serialized,
65 | AttrSerializable
66 | >> | null;
67 | public read>(
68 | token: Token,
69 | { optional }: { optional?: boolean } = {},
70 | ): Serialized,
73 | AttrSerializable
74 | >> | null {
75 | // Validate that the attribute exists.
76 | const serialized = this.#native.getAttribute(this.#name);
77 | if (serialized === null) {
78 | if (optional) {
79 | return null;
80 | } else {
81 | throw new Error(`Failed to read attribute "${
82 | this.#name}" because it was not set on the element.`);
83 | }
84 | }
85 |
86 | const serializer = resolveSerializer<
87 | Token,
88 | AttrSerializer,
89 | AttrSerializable
90 | >(token);
91 | return serializer.deserialize(serialized);
92 | }
93 |
94 | /**
95 | * Writes the underlying attribute by serializing the input value with the
96 | * {@link AttrSerializer} referenced by the provided token.
97 | *
98 | * @param value The value to serialize and write to the attribute.
99 | * @param token A token which resolves to an {@link AttrSerializer} to
100 | * serialize the attribute value with.
101 | */
102 | public write>(
103 | value: Value,
104 | token: Token,
105 | ): void {
106 | const serializer = resolveSerializer(token) as AttrSerializer;
107 | this.#native.setAttribute(this.#name, serializer.serialize(value));
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/base-component.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `base-component` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/base-component.test.ts:
--------------------------------------------------------------------------------
1 | import { BaseHydrateLifecycle, baseComponent } from './base-component.js';
2 | import { parseHtml } from './testing.js';
3 |
4 | describe('base-component', () => {
5 | afterEach(() => {
6 | for (const el of document.body.children) el.remove();
7 | });
8 |
9 | describe('baseComponent', () => {
10 | it('upgrades already rendered components when defined', () => {
11 | const el = document.createElement('already-rendered');
12 | document.body.append(el);
13 |
14 | const hydrate = jasmine.createSpy>('hydrate');
15 | const Comp = baseComponent('already-rendered', hydrate);
16 | Comp.define();
17 |
18 | expect(hydrate).toHaveBeenCalledTimes(1);
19 | });
20 |
21 | it('updates components rendered after definition', () => {
22 | const hydrate = jasmine.createSpy>('hydrate');
23 |
24 | const Comp = baseComponent('new-component', hydrate);
25 | Comp.define();
26 | expect(hydrate).not.toHaveBeenCalled();
27 |
28 | const comp = document.createElement('new-component');
29 | expect(hydrate).not.toHaveBeenCalled();
30 |
31 | document.body.appendChild(comp);
32 | expect(hydrate).toHaveBeenCalledTimes(1);
33 | });
34 |
35 | it('invokes hydrate callback without a `this` value', () => {
36 | // Can't use Jasmine spies here because they will default `this` to
37 | // `window` because they are run in "sloppy mode".
38 | let self: unknown = 'defined' /* initial value other than undefined */;
39 | function hydrate(this: unknown): void {
40 | self = this;
41 | }
42 |
43 | const Comp = baseComponent('this-component', hydrate);
44 | Comp.define();
45 |
46 | const comp = document.createElement('this-component');
47 | document.body.appendChild(comp);
48 |
49 | expect(self).toBeUndefined();
50 | });
51 |
52 | it('invokes hydrate callback with a `ComponentAccessor` of the component host', () => {
53 | const hydrate = jasmine.createSpy>('hydrate');
54 | const HostComponent = baseComponent('host-component', hydrate);
55 |
56 | const el = parseHtml(HostComponent, `
57 |
58 | Hello!
59 |
60 | `);
61 | document.body.appendChild(el);
62 |
63 | expect(hydrate).toHaveBeenCalledTimes(1);
64 | const [ accessor ] = hydrate.calls.first().args;
65 |
66 | // `ComponentAccessor` should be appropriately configured for the element.
67 | expect(accessor.query('span').access().read(String)).toBe('Hello!');
68 | });
69 |
70 | it('applies the component definition returned by the `hydrate` callback', () => {
71 | const hydrate = jasmine.createSpy>('hydrate')
72 | .and.returnValue({ foo: 'bar' });
73 |
74 | const CompWithDef = baseComponent('comp-with-def', hydrate);
75 |
76 | const el = parseHtml(CompWithDef, ``);
77 | document.body.appendChild(el);
78 |
79 | expect(el.foo).toBe('bar');
80 | });
81 |
82 | it('sets the class name', () => {
83 | const Comp = baseComponent('foo-bar-baz', () => {});
84 | expect(Comp.name).toBe('FooBarBaz');
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/src/base-component.ts:
--------------------------------------------------------------------------------
1 | import { ComponentAccessor } from './component-accessor.js';
2 | import { applyDefinition, ComponentDefinition, HydroActiveComponent } from './hydroactive-component.js';
3 | import { skewerCaseToPascalCase } from './utils/casing.js';
4 | import { createDefine, Defineable } from './utils/on-demand-definitions.js';
5 | import { Class } from './utils/types.js';
6 | import type { component } from './index.js'; // For JSDoc links.
7 |
8 | /** The type of the lifecycle hook invoked when the component hydrates. */
9 | export type BaseHydrateLifecycle =
10 | (host: ComponentAccessor) => CompDef | void;
11 |
12 | /**
13 | * Declares a base component of the given tag name with the provided hydration
14 | * callback.
15 | *
16 | * Base components do not depend on or support signal APIs. This makes them
17 | * lighter-weight than their {@link component} counterparts at the cost of
18 | * reduced functionality.
19 | *
20 | * This does *not* define the element (doesn't call `customElements.define`) to
21 | * preserve tree-shakability of the component. Call the static `.define` method
22 | * on the returned class to define the custom element if necessary.
23 | *
24 | * ```typescript
25 | * const MyElement = defineBaseComponent('my-element', () => {});
26 | * MyElement.define();
27 | * ```
28 | *
29 | * @param tagName The tag name to use for the custom element.
30 | * @param hydrate The function to trigger when the component hydrates.
31 | * @returns The custom element class.
32 | */
33 | export function baseComponent(
34 | tagName: string,
35 | hydrate: BaseHydrateLifecycle,
36 | ): Class & Defineable {
37 | const Component = class extends HydroActiveComponent {
38 | // Implement the on-demand definitions community protocol.
39 | static define = createDefine(tagName, this);
40 |
41 | public override hydrate(): void {
42 | // Hydrate this element.
43 | const compDef = hydrate(ComponentAccessor.fromComponent(this));
44 |
45 | // Apply the component definition to this element.
46 | if (compDef) applyDefinition(this, compDef);
47 | }
48 | }
49 |
50 | Object.defineProperty(Component, 'name', {
51 | value: skewerCaseToPascalCase(tagName),
52 | });
53 |
54 | return Component as unknown as
55 | Class & Defineable;
56 | }
57 |
--------------------------------------------------------------------------------
/src/component-accessor.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `component-accessor` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/component-accessor.test.ts:
--------------------------------------------------------------------------------
1 | import './testing/noop-component.js';
2 |
3 | import { Connector } from './connectable.js';
4 | import { ComponentAccessor } from './component-accessor.js';
5 |
6 | describe('component-accessor', () => {
7 | describe('ComponentAccessor', () => {
8 | describe('fromComponent', () => {
9 | it('creates a `ComponentAccessor` from a `HydroActiveComponent`', () => {
10 | const el = document.createElement('noop-component');
11 |
12 | const comp = ComponentAccessor.fromComponent(el);
13 |
14 | expect(comp.element).toBe(el);
15 | });
16 |
17 | it('creates a `ComponentAccessor` whose `shadow` queries a closed shadow root', () => {
18 | const el = document.createElement('noop-component');
19 | const shadowRoot = el.attachShadow({ mode: 'closed' });
20 |
21 | const comp = ComponentAccessor.fromComponent(el);
22 |
23 | expect(comp.shadow.root).toBe(shadowRoot);
24 | });
25 | });
26 |
27 | describe('connected', () => {
28 | it('proxies to the underlying `Connectable`', () => {
29 | const connector = Connector.from(/* isConnected */ () => false);
30 | spyOn(connector, 'connected');
31 |
32 | const el = document.createElement('noop-component');
33 | spyOnProperty(el, '_connectable', 'get').and.returnValue(connector);
34 |
35 | const accessor = ComponentAccessor.fromComponent(el);
36 |
37 | const onConnect = () => {};
38 | accessor.connected(onConnect);
39 |
40 | expect(connector.connected).toHaveBeenCalledOnceWith(onConnect);
41 | });
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/component-accessor.ts:
--------------------------------------------------------------------------------
1 | import { Connectable } from './connectable.js';
2 | import { ElementAccessor } from './element-accessor.js';
3 | import { HydroActiveComponent, elementInternalsMap } from './hydroactive-component.js';
4 | import { QueryRoot } from './query-root.js';
5 |
6 | /**
7 | * Wraps a {@link HydroActiveComponent} in a convenient wrapper for querying and
8 | * accessing it's contents with serializers.
9 | */
10 | export class ComponentAccessor
11 | extends ElementAccessor implements Connectable {
12 | readonly #connectable: Connectable;
13 |
14 | protected constructor(
15 | comp: Comp,
16 | root: QueryRoot,
17 | connectable: Connectable,
18 | ) {
19 | super(comp, root);
20 |
21 | this.#connectable = connectable;
22 | }
23 |
24 | /**
25 | * Provides a {@link ComponentAccessor} for the given component.
26 | *
27 | * @param comp The {@link Comp} to wrap in an accessor.
28 | * @returns A {@link ComponentAccessor} wrapping the given component.
29 | */
30 | public static fromComponent(comp: Comp):
31 | ComponentAccessor {
32 | return new ComponentAccessor(
33 | ...ComponentAccessor.fromComponentCtorArgs(comp),
34 | );
35 | }
36 |
37 | /**
38 | * Provides {@link ComponentAccessor} constructor arguments in a composable
39 | * manner for subclasses.
40 | *
41 | * @param comp The {@link Comp} to wrap with an accessor.
42 | * @returns Arguments for the constructor function of
43 | * {@link ComponentAccessor}.
44 | */
45 | protected static fromComponentCtorArgs(
46 | comp: Comp,
47 | ): [ comp: Comp, root: QueryRoot, connectable: Connectable ] {
48 | // It might be tempting to delete `comp` from `elementInternalsMap`, but we
49 | // can't because multiple `ComponentAccessors` might be created from the
50 | // same component and should have the same behavior.
51 | const internals = elementInternalsMap.get(comp);
52 | const root = QueryRoot.from(
53 | comp,
54 | // Get a closed shadow root from the component's internals if present.
55 | () => internals?.shadowRoot ?? null,
56 | );
57 |
58 | return [ comp, root, comp._connectable ];
59 | }
60 |
61 | public connected(...params: Parameters):
62 | ReturnType {
63 | return this.#connectable.connected(...params);
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/connectable.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `connectable` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/connectable.test.ts:
--------------------------------------------------------------------------------
1 | import { Connector, OnConnect, OnDisconnect } from './connectable.js';
2 |
3 | describe('connectable', () => {
4 | describe('Connector', () => {
5 | it('invokes the given callback on connect', () => {
6 | const onConnect = jasmine.createSpy('onConnect');
7 |
8 | const connector = Connector.from(/* isConnected */ () => false);
9 |
10 | connector.connected(onConnect);
11 | expect(onConnect).not.toHaveBeenCalled();
12 |
13 | connector.connect();
14 | expect(onConnect).toHaveBeenCalledOnceWith();
15 | });
16 |
17 | it('invokes the given callback on repeated connections', () => {
18 | const onConnect = jasmine.createSpy('onConnect');
19 |
20 | const connector = Connector.from(/* isConnected */ () => false);
21 |
22 | connector.connected(onConnect);
23 | expect(onConnect).not.toHaveBeenCalled();
24 |
25 | // Called on first connection.
26 | connector.connect();
27 | expect(onConnect).toHaveBeenCalledOnceWith();
28 |
29 | onConnect.calls.reset();
30 |
31 | connector.disconnect();
32 | expect(onConnect).not.toHaveBeenCalled();
33 |
34 | // Called again on second connection.
35 | connector.connect();
36 | expect(onConnect).toHaveBeenCalledOnceWith();
37 | });
38 |
39 | it('invokes the connected disposer on disconnect', () => {
40 | const onDisconnect = jasmine.createSpy('onDisconnect');
41 |
42 | const connector = Connector.from(/* isConnected */ () => false)
43 |
44 | connector.connected(() => onDisconnect);
45 | expect(onDisconnect).not.toHaveBeenCalled();
46 |
47 | connector.connect();
48 | expect(onDisconnect).not.toHaveBeenCalled();
49 |
50 | connector.disconnect();
51 | expect(onDisconnect).toHaveBeenCalledOnceWith();
52 | });
53 |
54 | it('refreshes the disconnect listener on each connection', () => {
55 | const onDisconnect1 = jasmine.createSpy('onDisconnect1');
56 | const onDisconnect2 = jasmine.createSpy('onDisconnect2');
57 |
58 | const connector = Connector.from(/* isConnected */ () => false);
59 |
60 | connector.connected(jasmine.createSpy().and.returnValues(
61 | onDisconnect1,
62 | onDisconnect2,
63 | ));
64 | expect(onDisconnect1).not.toHaveBeenCalled();
65 | expect(onDisconnect2).not.toHaveBeenCalled();
66 |
67 | // First connect.
68 | connector.connect();
69 | expect(onDisconnect1).not.toHaveBeenCalled();
70 | expect(onDisconnect2).not.toHaveBeenCalled();
71 |
72 | // First disconnect should only call the first disconnect listener.
73 | connector.disconnect();
74 | expect(onDisconnect1).toHaveBeenCalledOnceWith();
75 | expect(onDisconnect2).not.toHaveBeenCalled();
76 |
77 | onDisconnect1.calls.reset();
78 |
79 | // Second connect.
80 | connector.connect();
81 | expect(onDisconnect1).not.toHaveBeenCalled();
82 | expect(onDisconnect2).not.toHaveBeenCalled();
83 |
84 | // Second disconnect should only call the second disconnect listener.
85 | connector.disconnect();
86 | expect(onDisconnect1).not.toHaveBeenCalled();
87 | expect(onDisconnect2).toHaveBeenCalledOnceWith();
88 | });
89 |
90 | it('manages multiple connect callbacks', () => {
91 | const onConnect1 = jasmine.createSpy('onConnect1');
92 | const onConnect2 = jasmine.createSpy('onConnect2');
93 |
94 | const connector = Connector.from(/* isConnected */ () => false);
95 |
96 | connector.connected(onConnect1);
97 | connector.connected(onConnect2);
98 |
99 | expect(onConnect1).not.toHaveBeenCalled();
100 | expect(onConnect2).not.toHaveBeenCalled();
101 |
102 | connector.connect();
103 | expect(onConnect1).toHaveBeenCalledOnceWith();
104 | expect(onConnect2).toHaveBeenCalledOnceWith();
105 | });
106 |
107 | it('invokes the connect callback immediately when already connected', () => {
108 | const onConnect = jasmine.createSpy('onConnect');
109 |
110 | const connector = Connector.from(/* isConnected */ () => true);
111 | connector.connected(onConnect);
112 | expect(onConnect).toHaveBeenCalledOnceWith();
113 | });
114 |
115 | it('does not invoke the connect callback when disconnected', () => {
116 | const onConnect = jasmine.createSpy('onConnect');
117 |
118 | const connector = Connector.from(/* isConnected */ () => false);
119 |
120 | // Connect and disconnect the element.
121 | connector.connect();
122 | connector.disconnect();
123 |
124 | // Should not be called because the element is currently disconnected.
125 | connector.connected(onConnect);
126 | expect(onConnect).not.toHaveBeenCalled();
127 |
128 | // Should be called when connected.
129 | connector.connect();
130 | expect(onConnect).toHaveBeenCalledOnceWith();
131 | });
132 | });
133 | });
134 |
--------------------------------------------------------------------------------
/src/connectable.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The type of the function invoked on connect. May optionally return a
3 | * disconnect function to be invoked when the component is disconnected.
4 | */
5 | export type OnConnect = () => OnDisconnect | void;
6 |
7 | /** The type of the function invoked on disconnect. */
8 | export type OnDisconnect = () => void;
9 |
10 | /**
11 | * Represents a component lifecycle associated with connecting to and
12 | * disconnecting from the document which can be leveraged to acquire and release
13 | * resources.
14 | */
15 | export interface Connectable {
16 | /**
17 | * Sets up the given handler to be invoked whenever the underlying element is
18 | * connected to the DOM. If the handler returns a function, that function will
19 | * be invoked the next time the component is disconnected. This provides a
20 | * useful API for maintaining state which needs to be cleaned up while
21 | * avoiding * memory leaks in the component.
22 | *
23 | * The connect handler may be invoked multiple times if the component is
24 | * disconnected and reconnected to the DOM. Any returned disconnect function
25 | * is called at most once.
26 | *
27 | * Example:
28 | *
29 | * ```typescript
30 | * defineComponent('my-component', (host) => {
31 | * host.connected(() => {
32 | * console.log('I am connected!');
33 | *
34 | * // Optional cleanup work to be run on disconnect.
35 | * return () => {
36 | * console.log('I am disconnected!');
37 | * };
38 | * });
39 | * });
40 | * ```
41 | *
42 | * @param onConnect The function to invoke when the component is connected.
43 | */
44 | connected(onConnect: OnConnect): void;
45 | }
46 |
47 | /**
48 | * A {@link Connectable} implementation which allows users to directly control
49 | * when the associated component connects to or disconnects from the document.
50 | */
51 | export class Connector implements Connectable {
52 | readonly #connectedCallbacks: OnConnect[] = [];
53 | readonly #disconnectedCallbacks: OnDisconnect[] = [];
54 | readonly #isConnected: () => boolean;
55 |
56 | private constructor(isConnected: () => boolean) {
57 | this.#isConnected = isConnected;
58 | }
59 |
60 | /**
61 | * Provides a {@link Connector} from the given input.
62 | *
63 | * @param isConnected A function which returns whether or not the associated
64 | * component is currently connected to the document.
65 | * @returns A {@link Connector} for the associated component.
66 | */
67 | public static from(isConnected: () => boolean): Connector {
68 | return new Connector(isConnected);
69 | }
70 |
71 | public connected(onConnect: OnConnect): void {
72 | this.#connectedCallbacks.push(onConnect);
73 |
74 | if (this.#isConnected()) this.#invokeOnConnect(onConnect);
75 | }
76 |
77 | /** Trigger the {@link connected} callbacks. */
78 | public connect(): void {
79 | for (const connectedCallback of this.#connectedCallbacks) {
80 | this.#invokeOnConnect(connectedCallback);
81 | }
82 | }
83 |
84 | /** Trigger any registered disconnect callbacks. */
85 | public disconnect(): void {
86 | for (const onDisconnect of this.#disconnectedCallbacks) {
87 | onDisconnect();
88 | }
89 |
90 | // Clear all the disconnect listeners. They will be re-added when their
91 | // associated connect listeners are invoked.
92 | this.#disconnectedCallbacks.splice(0, this.#disconnectedCallbacks.length);
93 | }
94 |
95 | /**
96 | * Invokes the given {@link OnConnect} handler and registers its disconnect
97 | * callback if provided.
98 | */
99 | #invokeOnConnect(onConnect: OnConnect): void {
100 | const onDisconnect = onConnect();
101 | if (onDisconnect) this.#disconnectedCallbacks.push(onDisconnect);
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/custom-elements.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `custom-elements` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/custom-elements.test.ts:
--------------------------------------------------------------------------------
1 | import { isCustomElement, isUpgraded } from './custom-elements.js';
2 |
3 | describe('custom-elements', () => {
4 | describe('isCustomElement', () => {
5 | it('returns `true` when given a defined custom element', () => {
6 | customElements.define('ce-defined-test', class extends HTMLElement {});
7 |
8 | expect(isCustomElement(document.createElement('ce-defined-test')))
9 | .toBeTrue();
10 | });
11 |
12 | it('returns `true` when given a not-yet-defined custom element', () => {
13 | expect(isCustomElement(document.createElement('ce-not-defined-test')))
14 | .toBeTrue();
15 | });
16 |
17 | it('returns `false` when given a native HTML element', () => {
18 | expect(isCustomElement(document.createElement('div'))).toBeFalse();
19 | });
20 | });
21 |
22 | describe('isUpgraded', () => {
23 | it('returns `true` when given a defined custom element', () => {
24 | customElements.define('ce-defined-test-2', class extends HTMLElement {});
25 |
26 | expect(isUpgraded(document.createElement('ce-defined-test-2')))
27 | .toBeTrue();
28 | });
29 |
30 | it('returns `false` when given a not-yet-defined custom element', () => {
31 | expect(isUpgraded(document.createElement('ce-not-yet-defined-test')))
32 | .toBeFalse();
33 | });
34 |
35 | it('returns `false` when given a defined class but non-upgraded element', () => {
36 | customElements.define('ce-defined-test-3', class extends HTMLElement {});
37 |
38 | const backgroundDocument = document.implementation.createHTMLDocument();
39 |
40 | // Background elements are not upgraded immediately.
41 | const el = backgroundDocument.createElement('ce-defined-test-3');
42 | expect(isUpgraded(el)).toBeFalse();
43 |
44 | // Adopt and manually upgrade the element.
45 | document.adoptNode(el);
46 | customElements.upgrade(el);
47 | expect(isUpgraded(el)).toBeTrue();
48 | });
49 |
50 | it('throws when given a native HTML element', () => {
51 | expect(() => isUpgraded(document.createElement('div')))
52 | .toThrowError(/not a custom element/);
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/custom-elements.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines utilities for interacting with custom elements. */
2 |
3 | /**
4 | * Returns whether or not the given element is a custom element. The custom
5 | * element may or may not be defined.
6 | *
7 | * @param el The {@link Element} to check.
8 | * @returns Whether or not `el` is a custom element.
9 | */
10 | export function isCustomElement(el: Element): boolean {
11 | return el.tagName.includes('-');
12 | }
13 |
14 | /**
15 | * Returns whether or not the given custom element has been defined and
16 | * upgraded.
17 | *
18 | * Note that "defining" and "upgrading" an element are two separate things which
19 | * can happen at separate times. Typically, defining an element upgrades all
20 | * existing elements on the page, and constructing a new element immediately
21 | * defines it. However there are edge cases where an element is defined but
22 | * certain instances may not be upgraded. For example, custom elements created
23 | * in background documents are not upgraded until they are adopted into the
24 | * primary document, even if the element was already defined.
25 | *
26 | * @param el The {@link Element} to check.
27 | * @returns Whether or not `el` has been defined.
28 | * @throws When the given element is not a custom element.
29 | */
30 | export function isUpgraded(el: Element): boolean {
31 | if (!isCustomElement(el)) {
32 | throw new Error(`\`${el.tagName.toLowerCase()}\` is not a custom element.`);
33 | }
34 |
35 | const CeClass = customElements.get(el.tagName.toLowerCase());
36 | if (!CeClass) return false;
37 |
38 | return el instanceof CeClass;
39 | }
40 |
--------------------------------------------------------------------------------
/src/dehydrated.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `dehydrated` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/attrs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Attributes Demo
5 |
6 |
7 |
15 |
16 |
17 | Attributes Demo
18 |
19 |
20 |
21 |
22 | Read Attribute
23 |
24 | Hello, -!
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/demo/attrs/read-attr.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `read-attr` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/attrs/read-attr.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { ReadAttr } from './read-attr.js';
3 |
4 | describe('read-attr', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function render({ id }: { id: number }) {
10 | return parseHtml(ReadAttr, `
11 |
12 | Read Attribute
13 |
14 | Hello, -!
15 |
16 | `);
17 | }
18 |
19 | describe('ReadAttr', () => {
20 | it('renders the user name on hydration', async () => {
21 | const el = render({ id: 1234 });
22 | document.body.appendChild(el);
23 |
24 | await el.stable();
25 |
26 | expect(el.querySelector('span')!.textContent).toBe('Devel');
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/demo/attrs/read-attr.ts:
--------------------------------------------------------------------------------
1 | import { AttrAccessor, baseComponent } from 'hydroactive';
2 |
3 | /** Reads an attribute from the host element. */
4 | export const ReadAttr = baseComponent('read-attr', (host) => {
5 | // `host` is a `ComponentAccessor` of the host element (`read-attr`).
6 | // `ComponentAccessor` has an `attr` method which provides an `AttrAccessor`.
7 | const idAttr: AttrAccessor = host.attr('user-id');
8 |
9 | // `AttrAccessor` has its own `read` method which reads from the attribute.
10 | const id: number = idAttr.read(Number);
11 |
12 | // Look up the username based on the ID.
13 | const username = getUserById(id);
14 |
15 | // Update the `` tag to have the username read from the attribute.
16 | host.query('span').access().write(username, String);
17 | });
18 |
19 | ReadAttr.define();
20 |
21 | declare global {
22 | interface HTMLElementTagNameMap {
23 | 'read-attr': InstanceType;
24 | }
25 | }
26 |
27 | function getUserById(id: number): string {
28 | switch (id) {
29 | case 1234: return 'Devel';
30 | default: throw new Error(`Unknown user id: ${id}.`);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/demo/composition/composed-counter.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `composed-counter` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/composition/composed-counter.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { CounterController, CounterDisplay } from './composed-counter.js';
3 |
4 | describe('composed-counter', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function renderDisplay({ count }: { count: number }) {
10 | return parseHtml(CounterDisplay, `
11 |
12 | The current count is: ${count}.
13 |
14 | `);
15 | }
16 |
17 | function renderController({ count }: { count: number }) {
18 | return parseHtml(CounterController, `
19 |
20 | Composed Counter
21 |
22 | ${renderDisplay({ count }).outerHTML}
23 |
24 |
25 |
26 |
27 | `, [ CounterDisplay ]);
28 | }
29 |
30 | describe('CounterDisplay', () => {
31 | describe('decrement', () => {
32 | it('decrements the count', async () => {
33 | const el = renderDisplay({ count: 5 });
34 | document.body.appendChild(el);
35 |
36 | el.decrement();
37 |
38 | await el.stable();
39 |
40 | expect(el.querySelector('span')!.textContent).toBe('4');
41 | });
42 | });
43 |
44 | describe('increment', () => {
45 | it('increments the count', async () => {
46 | const el = renderDisplay({ count: 5 });
47 | document.body.appendChild(el);
48 |
49 | el.increment();
50 |
51 | await el.stable();
52 |
53 | expect(el.querySelector('span')!.textContent).toBe('6');
54 | });
55 | });
56 | });
57 |
58 | describe('CounterController', () => {
59 | it('decrements the count when clicked', async () => {
60 | const ctrl = renderController({ count: 5 });
61 | document.body.appendChild(ctrl);
62 |
63 | (ctrl.querySelector('button#decrement')! as HTMLButtonElement).click();
64 |
65 | const display = ctrl.querySelector('counter-display')!;
66 | await display.stable();
67 |
68 | expect(display.querySelector('span')!.textContent).toBe('4');
69 | });
70 |
71 | it('increments the count when clicked', async () => {
72 | const ctrl = renderController({ count: 5 });
73 | document.body.appendChild(ctrl);
74 |
75 | (ctrl.querySelector('button#increment')! as HTMLButtonElement).click();
76 |
77 | const display = ctrl.querySelector('counter-display')!;
78 | await display.stable();
79 |
80 | expect(display.querySelector('span')!.textContent).toBe('6');
81 | });
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/demo/composition/composed-counter.ts:
--------------------------------------------------------------------------------
1 | import { component } from 'hydroactive';
2 | import { live } from 'hydroactive/signal-accessors.js';
3 |
4 | /**
5 | * Displays the current count and provides `decrement` and `increment` methods
6 | * for modifying the count.
7 | */
8 | export const CounterDisplay = component('counter-display', (host) => {
9 | // The `count` state lives within `counter-display`.
10 | const count = live(host.query('span').access(), host, Number);
11 |
12 | // These functions are exposed on the `counter-display` custom element.
13 | return {
14 | decrement(): void {
15 | count.set(count() - 1);
16 | },
17 |
18 | increment(): void {
19 | count.set(count() + 1);
20 | },
21 | };
22 | });
23 |
24 | // No need to call `CounterDisplay.define()` here, the component is defined
25 | // automatically by usage in `CounterController`.
26 | // `CounterDisplay` definition is pure and tree-shakable!
27 |
28 | declare global {
29 | interface HTMLElementTagNameMap {
30 | 'counter-display': InstanceType;
31 | }
32 | }
33 |
34 | /**
35 | * Controls an underlying `counter-display` by incrementing and decrementing it
36 | * based on button clicks.
37 | */
38 | export const CounterController = component('counter-controller', (host) => {
39 | // Get a reference to the underlying `counter-display` element.
40 | // Automatically defines `CounterDisplay` if it isn't already defined.
41 | const inner = host.query('counter-display').access(CounterDisplay).element;
42 |
43 | // Bind the button clicks to modifying the counter.
44 | host.query('button#decrement').access().listen(host, 'click', () => {
45 | inner.decrement();
46 | });
47 | host.query('button#increment').access().listen(host, 'click', () => {
48 | inner.increment();
49 | });
50 | });
51 |
52 | CounterController.define();
53 |
54 | declare global {
55 | interface HTMLElementTagNameMap {
56 | 'counter-controller': InstanceType;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/demo/composition/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Composition Demo
5 |
6 |
7 |
15 |
16 |
17 | Composition Demo
18 |
19 |
20 |
21 |
22 | Composed Counter
23 |
24 |
25 | The current count is: 5.
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/demo/counter/auto-counter.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `auto-counter` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/counter/auto-counter.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { AutoCounter } from './auto-counter.js';
3 |
4 | describe('auto-counter', () => {
5 | beforeEach(() => { jasmine.clock().install(); });
6 | afterEach(() => { jasmine.clock().uninstall(); });
7 |
8 | afterEach(() => {
9 | for (const node of document.body.childNodes) node.remove();
10 | });
11 |
12 | function render({ count }: { count: number }) {
13 | return parseHtml(AutoCounter, `
14 |
15 | The current count is: ${count}.
16 |
17 | `);
18 | }
19 |
20 | describe('AutoCounter', () => {
21 | it('does not re-render on hydration', async () => {
22 | const el = render({ count: 5 });
23 | document.body.appendChild(el);
24 |
25 | await el.stable();
26 |
27 | expect(el.querySelector('span')!.textContent).toBe('5');
28 | });
29 |
30 | it('updates the count every second', async () => {
31 | const el = render({ count: 5 });
32 | document.body.appendChild(el);
33 |
34 | jasmine.clock().tick(1_000);
35 | await el.stable();
36 |
37 | expect(el.querySelector('span')!.textContent).toBe('6');
38 | });
39 |
40 | it('pauses the count while disconnected', async () => {
41 | const el = render({ count: 5 });
42 |
43 | document.body.appendChild(el);
44 | el.remove(); // Should pause timer.
45 |
46 | jasmine.clock().tick(1_000);
47 | await el.stable();
48 |
49 | // Should not have incremented.
50 | expect(el.querySelector('span')!.textContent).toBe('5');
51 | });
52 |
53 | it('resumes the count when reconnected', async () => {
54 | const el = render({ count: 5 });
55 |
56 | document.body.appendChild(el);
57 | el.remove(); // Should pause timer.
58 |
59 | jasmine.clock().tick(3_000);
60 |
61 | document.body.appendChild(el); // Should resume timer.
62 |
63 | jasmine.clock().tick(1_000);
64 | await el.stable();
65 |
66 | // Should have incremented only once.
67 | expect(el.querySelector('span')!.textContent).toBe('6');
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/demo/counter/auto-counter.ts:
--------------------------------------------------------------------------------
1 | import { component } from 'hydroactive';
2 | import { live } from 'hydroactive/signal-accessors.js';
3 |
4 | /** Automatically increments the count over time. */
5 | export const AutoCounter = component('auto-counter', (host) => {
6 | // Create a "live" binding of the `` element's text content, but
7 | // interpreted as a `number`. Automatically parses the value.
8 | const count = live(host.query('span').access(), host, Number);
9 |
10 | // This is the `hydrate` function, it is only called once per-component
11 | // instance on hydration.
12 |
13 | // Run some code when the component is connected to or disconnected from the
14 | // document. This can be used to clean up resources which might cause the
15 | // component to leak memory when not in use.
16 | host.connected(() => {
17 | // Executed when the component is connected to the DOM (or on hydration if
18 | // already connected). Create a timer to automatically update the count
19 | // every second.
20 | const handle = setInterval(() => {
21 | count.set(count() + 1);
22 | }, 1_000);
23 |
24 | // Executed when the component is disconnected from the DOM. Used to clean
25 | // up resources created above.
26 | return () => {
27 | // Disable the timer so the component stops incrementing and allow the
28 | // component to be garbage collected if no longer in use.
29 | clearInterval(handle);
30 | };
31 | });
32 | });
33 |
34 | AutoCounter.define();
35 |
36 | declare global {
37 | interface HTMLElementTagNameMap {
38 | 'auto-counter': InstanceType;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/demo/counter/bind-counter.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `bind-counter` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/counter/bind-counter.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { BindCounter } from './bind-counter.js';
3 |
4 | describe('bind-counter', () => {
5 | beforeEach(() => { jasmine.clock().install(); });
6 | afterEach(() => { jasmine.clock().uninstall(); });
7 |
8 | afterEach(() => {
9 | for (const node of document.body.childNodes) node.remove();
10 | });
11 |
12 | function render({ count }: { count: number }) {
13 | return parseHtml(BindCounter, `
14 |
15 | The current count is: ${count}.
16 |
17 | `);
18 | }
19 |
20 | describe('BindCounter', () => {
21 | it('does not re-render on hydration', async () => {
22 | const el = render({ count: 5 });
23 | document.body.appendChild(el);
24 |
25 | await el.stable();
26 |
27 | expect(el.querySelector('span')!.textContent).toBe('5');
28 | });
29 |
30 | it('updates the count every second', async () => {
31 | const el = render({ count: 5 });
32 | document.body.appendChild(el);
33 |
34 | jasmine.clock().tick(1_000);
35 | await el.stable();
36 |
37 | expect(el.querySelector('span')!.textContent).toBe('6');
38 | });
39 |
40 | it('pauses the count while disconnected', async () => {
41 | const el = render({ count: 5 });
42 |
43 | document.body.appendChild(el);
44 | el.remove(); // Should pause timer.
45 |
46 | jasmine.clock().tick(1_000);
47 | await el.stable();
48 |
49 | // Should not have incremented.
50 | expect(el.querySelector('span')!.textContent).toBe('5');
51 | });
52 |
53 | it('resumes the count when reconnected', async () => {
54 | const el = render({ count: 5 });
55 |
56 | document.body.appendChild(el);
57 | el.remove(); // Should pause timer.
58 |
59 | jasmine.clock().tick(3_000);
60 |
61 | document.body.appendChild(el); // Should resume timer.
62 |
63 | jasmine.clock().tick(1_000);
64 | await el.stable();
65 |
66 | // Should have incremented only once.
67 | expect(el.querySelector('span')!.textContent).toBe('6');
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/demo/counter/bind-counter.ts:
--------------------------------------------------------------------------------
1 | import { Dehydrated, ElementAccessor, component } from 'hydroactive';
2 | import { bind } from 'hydroactive/signal-accessors.js';
3 | import { WriteableSignal, signal } from 'hydroactive/signals.js';
4 |
5 | /**
6 | * Automatically increments the count over time. Uses `bind` instead of `live`
7 | * to demonstrate the underlying primitives.
8 | */
9 | export const BindCounter = component('bind-counter', (host) => {
10 | // Queries the DOM for the `` tag.
11 | const span: Dehydrated = host.query('span');
12 |
13 | // Verifies that the element does not need to be hydrated and returns an
14 | // `ElementAccessor`, a convenient wrapper with methods to easily interact
15 | // with the element.
16 | const label: ElementAccessor = span.access();
17 |
18 | // Reads the current text content of the label and interprets it as a
19 | // `number`.
20 | const initial: number = label.read(Number);
21 |
22 | // Creates a signal with the given initial value.
23 | const count: WriteableSignal = signal(initial);
24 |
25 | // Binds the signal back to the `` tag. Anytime `count` changes, the
26 | // `` will be automatically updated.
27 | bind(label, host, Number, () => count());
28 |
29 | // ^ `live(label, host, Number)` implicitly does all of the above.
30 |
31 | host.connected(() => {
32 | const handle = setInterval(() => {
33 | count.set(count() + 1);
34 | }, 1_000);
35 |
36 | return () => {
37 | clearInterval(handle);
38 | };
39 | });
40 | });
41 |
42 | BindCounter.define();
43 |
44 | declare global {
45 | interface HTMLElementTagNameMap {
46 | 'bind-counter': InstanceType;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/demo/counter/button-counter.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `button-counter` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/counter/button-counter.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { ButtonCounter } from './button-counter.js';
3 |
4 | describe('button-counter', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function render({ count }: { count: number }) {
10 | return parseHtml(ButtonCounter, `
11 |
12 | The current count is: ${count}.
13 |
14 |
15 |
16 | `);
17 | }
18 |
19 | describe('ButtonCounter', () => {
20 | it('does not re-render on hydration', async () => {
21 | const el = render({ count: 5 });
22 | document.body.appendChild(el);
23 |
24 | await el.stable();
25 |
26 | expect(el.querySelector('span')!.textContent).toBe('5');
27 | });
28 |
29 | it('decrements on click', async () => {
30 | const el = render({ count: 5 });
31 | document.body.appendChild(el);
32 |
33 | (el.querySelector('#decrement')! as HTMLElement).click();
34 |
35 | await el.stable();
36 |
37 | expect(el.querySelector('span')!.textContent).toBe('4');
38 | });
39 |
40 | it('increments on click', async () => {
41 | const el = render({ count: 5 });
42 | document.body.appendChild(el);
43 |
44 | (el.querySelector('#increment')! as HTMLElement).click();
45 |
46 | await el.stable();
47 |
48 | expect(el.querySelector('span')!.textContent).toBe('6');
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/src/demo/counter/button-counter.ts:
--------------------------------------------------------------------------------
1 | import { component } from 'hydroactive';
2 | import { live } from 'hydroactive/signal-accessors.js';
3 |
4 | /**
5 | * A counter which increments and decrements the count based on button clicks.
6 | */
7 | export const ButtonCounter = component('button-counter', (host) => {
8 | const count = live(host.query('span').access(), host, Number);
9 |
10 | // Listen for click events and update the count accordingly. Event listeners
11 | // are automatically removed when the component is disconnected from the DOM,
12 | // no need to manually remove them.
13 | host.query('#decrement').access().listen(host, 'click', () => {
14 | count.set(count() - 1);
15 | });
16 | host.query('#increment').access().listen(host, 'click', () => {
17 | count.set(count() + 1);
18 | });
19 | });
20 |
21 | ButtonCounter.define();
22 |
23 | declare global {
24 | interface HTMLElementTagNameMap {
25 | 'button-counter': InstanceType;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/demo/counter/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Counter Demo
5 |
6 |
7 |
15 |
16 |
17 | Counter Demo
18 |
19 |
20 |
21 |
22 | Auto Counter
23 |
24 | The current count is: 5.
25 |
26 |
27 |
28 |
29 |
30 | Bind Counter
31 |
32 | The current count is: 10.
33 |
34 |
35 |
36 |
37 |
38 | Button Counter
39 |
40 | The current count is: 15.
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/demo/hello-world/hello-world.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `hello-world` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/hello-world/hello-world.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { HelloWorld } from './hello-world.js';
3 |
4 | describe('hello-world', () => {
5 | describe('HelloWorld', () => {
6 | it('updates the name to "HydroActive"', async () => {
7 | const el = parseHtml(HelloWorld, `
8 |
9 | Hello, World!
10 |
11 | `);
12 | document.body.appendChild(el);
13 |
14 | await el.stable();
15 |
16 | expect(el.querySelector('#name')!.textContent).toBe('HydroActive');
17 | });
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/demo/hello-world/hello-world.ts:
--------------------------------------------------------------------------------
1 | import { baseComponent } from 'hydroactive';
2 |
3 | /** Says hello to HydroActive on hydration. */
4 | export const HelloWorld = baseComponent('hello-world', (host) => {
5 | host.query('span#name').access().write('HydroActive', String);
6 | });
7 |
8 | HelloWorld.define();
9 |
10 | // Declare the component tag name for improved type inference in TypeScript.
11 | declare global {
12 | interface HTMLElementTagNameMap {
13 | 'hello-world': InstanceType;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/demo/hello-world/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hello World Demo
5 |
6 |
7 |
15 |
16 |
17 | Hello World Demo
18 |
19 |
20 |
21 |
22 | Hello World
23 |
24 | Hello, World!
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/demo/hooks/custom-hook.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `custom-hook` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/hooks/custom-hook.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { CustomHook } from './custom-hook.js';
3 |
4 | describe('custom-hook', () => {
5 | beforeEach(() => { jasmine.clock().install(); });
6 | afterEach(() => { jasmine.clock().uninstall(); });
7 |
8 | afterEach(() => {
9 | for (const node of document.body.childNodes) node.remove();
10 | });
11 |
12 | function render({ count }: { count: number }) {
13 | return parseHtml(CustomHook, `
14 |
15 | The current count is: ${count}.
16 |
17 | `);
18 | }
19 |
20 | describe('CustomHook', () => {
21 | it('does not re-render on hydration', async () => {
22 | const el = render({ count: 5 });
23 | document.body.appendChild(el);
24 |
25 | await el.stable();
26 |
27 | expect(el.querySelector('span')!.textContent).toBe('5');
28 | });
29 |
30 | it('updates the count every second', async () => {
31 | const el = render({ count: 5 });
32 | document.body.appendChild(el);
33 |
34 | jasmine.clock().tick(1_000);
35 | await el.stable();
36 |
37 | expect(el.querySelector('span')!.textContent).toBe('6');
38 | });
39 |
40 | it('pauses the count while disconnected', async () => {
41 | const el = render({ count: 5 });
42 |
43 | document.body.appendChild(el);
44 | el.remove(); // Should pause timer.
45 |
46 | jasmine.clock().tick(1_000);
47 | await el.stable();
48 |
49 | // Should not have incremented.
50 | expect(el.querySelector('span')!.textContent).toBe('5');
51 | });
52 |
53 | it('resumes the count when reconnected', async () => {
54 | const el = render({ count: 5 });
55 |
56 | document.body.appendChild(el);
57 | el.remove(); // Should pause timer.
58 |
59 | jasmine.clock().tick(3_000);
60 |
61 | document.body.appendChild(el); // Should resume timer.
62 |
63 | jasmine.clock().tick(1_000);
64 | await el.stable();
65 |
66 | // Should have incremented only once.
67 | expect(el.querySelector('span')!.textContent).toBe('6');
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/demo/hooks/custom-hook.ts:
--------------------------------------------------------------------------------
1 | import { ComponentAccessor, HydroActiveComponent, component } from 'hydroactive';
2 | import { bind } from 'hydroactive/signal-accessors.js';
3 | import { Signal, signal } from 'hydroactive/signals.js';
4 |
5 | /** Demonstrates a custom hook for controlling the count timer. */
6 | export const CustomHook = component('custom-hook', (host) => {
7 | const initial = host.query('span').access().read(Number);
8 |
9 | // Create a signal which is automatically incremented every second. Bound to
10 | // the component's lifecycle.
11 | const count = useTimer(host, initial);
12 |
13 | bind(host.query('span').access(), host, Number, () => count());
14 | });
15 |
16 | CustomHook.define();
17 |
18 | declare global {
19 | interface HTMLElementTagNameMap {
20 | 'custom-hook': InstanceType;
21 | }
22 | }
23 |
24 | /**
25 | * Custom "hook" to automatically increment a signal. Can be reused across
26 | * multiple components. The only trick here is accepting `host` as an input
27 | * parameter so it can tie into the component's lifecycle appropriately.
28 | */
29 | function useTimer(
30 | host: ComponentAccessor,
31 | initial: number,
32 | ): Signal {
33 | const count = signal(initial);
34 |
35 | host.connected(() => {
36 | const handle = setInterval(() => {
37 | count.set(count() + 1);
38 | }, 1_000);
39 |
40 | return () => {
41 | clearInterval(handle);
42 | };
43 | });
44 |
45 | // Use `.readonly` to convert the `WriteableSignal` into a readonly `Signal`.
46 | // This prevents anyone from accidentally calling `set` on the return value.
47 | return count.readonly();
48 | }
49 |
--------------------------------------------------------------------------------
/src/demo/hooks/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hooks Demo
5 |
6 |
7 |
15 |
16 |
17 | Hooks Demo
18 |
19 |
20 |
21 |
22 | Custom Hook
23 |
24 | The current count is: 5.
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-comp.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `deferred-comp` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-comp.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { DeferredComp } from './deferred-comp.js';
3 |
4 | describe('deferred-comp', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function render(): InstanceType {
10 | return parseHtml(DeferredComp, `
11 |
12 | Hello, World!
13 |
14 | `);
15 | }
16 |
17 | describe('DeferredComp', () => {
18 | it('does not hydrate on connection', () => {
19 | const el = render();
20 | document.body.appendChild(el);
21 |
22 | // Component upgrades immediately.
23 | expect(el).toBeInstanceOf(DeferredComp);
24 |
25 | // Hydration should be delayed.
26 | expect(el.querySelector('span')!.textContent).toBe('World');
27 | });
28 |
29 | it('hydrates when `defer-hydration` is removed', async () => {
30 | const el = render();
31 | document.body.appendChild(el);
32 |
33 | el.removeAttribute('defer-hydration');
34 |
35 | // Hydration is synchronous, but rendering is not.
36 | await el.stable();
37 |
38 | expect(el.querySelector('span')!.textContent).toBe('HydroActive');
39 | });
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-comp.ts:
--------------------------------------------------------------------------------
1 | import { baseComponent } from 'hydroactive';
2 |
3 | /** Says hello to HydroActive on hydration. */
4 | export const DeferredComp = baseComponent('deferred-comp', (host) => {
5 | host.query('span').access().write('HydroActive', String);
6 | });
7 |
8 | DeferredComp.define();
9 |
10 | declare global {
11 | interface HTMLElementTagNameMap {
12 | 'deferred-comp': InstanceType;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-composition-child.ts:
--------------------------------------------------------------------------------
1 | import { baseComponent } from 'hydroactive';
2 |
3 | /** Hydrates by reading the speaker's name from the DOM and exposing it. */
4 | export const DeferredCompositionChild = baseComponent(
5 | 'deferred-composition-child',
6 | (host) => {
7 | const speaker = host.query('span#subject').access().read(String);
8 | console.log(`Hydrating ${speaker}!`);
9 |
10 | host.query('span#target').access().write('HydroActive', String);
11 |
12 | return {
13 | /** Provides hydrated speaker name. */
14 | getSpeakerName(): string {
15 | return speaker;
16 | }
17 | };
18 | },
19 | );
20 |
21 | DeferredCompositionChild.define();
22 |
23 | declare global {
24 | interface HTMLElementTagNameMap {
25 | 'deferred-composition-child': InstanceType;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-composition.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `deferred-composition` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-composition.test.ts:
--------------------------------------------------------------------------------
1 | import { hydrate, isHydrated } from 'hydroactive';
2 | import { parseHtml } from 'hydroactive/testing.js';
3 | import { DeferredComposition } from './deferred-composition.js';
4 | import { DeferredCompositionChild } from './deferred-composition-child.js';
5 |
6 | describe('deferred-composition', () => {
7 | afterEach(() => {
8 | for (const node of document.body.childNodes) node.remove();
9 | });
10 |
11 | function render(): InstanceType {
12 | return parseHtml(DeferredComposition, `
13 |
14 |
15 |
16 | Devel says "Hello!" to
17 | World!
18 |
19 |
20 |
21 |
22 |
23 | Owen says "Hello!" to
24 | World!
25 |
26 |
27 |
28 | The two speakers are named - and
29 | -.
30 |
31 | `, [ DeferredCompositionChild ]);
32 | }
33 |
34 | describe('DeferredComposition', () => {
35 | it('does not block hydration of the first child', () => {
36 | const el = render();
37 | document.body.append(el);
38 |
39 | const firstChild = el.querySelector('#first')! as
40 | InstanceType;
41 | expect(isHydrated(firstChild)).toBeTrue();
42 |
43 | expect(firstChild.querySelector('#target')!.textContent!)
44 | .toBe('HydroActive');
45 | expect(firstChild.getSpeakerName()).toBe('Devel');
46 | });
47 |
48 | it('hydrates the second child', () => {
49 | const el = render();
50 | document.body.append(el);
51 |
52 | hydrate(el, DeferredComposition);
53 |
54 | const secondChild = el.querySelector('#second')! as
55 | InstanceType;
56 | expect(isHydrated(secondChild)).toBeTrue();
57 |
58 | expect(secondChild.querySelector('#target')!.textContent!)
59 | .toBe('HydroActive');
60 | expect(secondChild.getSpeakerName()).toBe('Owen');
61 | });
62 |
63 | it('hydrates the speaker names', async () => {
64 | const el = render();
65 | document.body.append(el);
66 |
67 | hydrate(el, DeferredComposition);
68 | await el.stable();
69 |
70 | expect(el.querySelector('#first-speaker')!.textContent!).toBe('Devel');
71 | expect(el.querySelector('#second-speaker')!.textContent!).toBe('Owen');
72 | });
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/src/demo/hydration/deferred-composition.ts:
--------------------------------------------------------------------------------
1 | import { baseComponent } from 'hydroactive';
2 | import { DeferredCompositionChild } from './deferred-composition-child.js';
3 |
4 | /** Demonstrates accessing and hydrating child components. */
5 | export const DeferredComposition = baseComponent(
6 | 'deferred-composition',
7 | (host) => {
8 | // `.access` asserts the component is already hydrated.
9 | const firstName = host.query('#first')
10 | .access(DeferredCompositionChild)
11 | .element.getSpeakerName();
12 | host.query('#first-speaker').access().write(firstName, String);
13 |
14 | // `.hydrate` hydrates the component immediately.
15 | const secondName = host.query('#second')
16 | .hydrate(DeferredCompositionChild)
17 | .element.getSpeakerName();
18 | host.query('#second-speaker').access().write(secondName, String);
19 | },
20 | );
21 |
22 | DeferredComposition.define();
23 |
24 | declare global {
25 | interface HTMLElementTagNameMap {
26 | 'deferred-composition': InstanceType;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/demo/hydration/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hydration Demo
5 |
6 |
7 |
15 |
16 |
17 | Hydration Demo
18 |
19 |
20 |
21 |
22 | Deferred Component
23 |
24 | Hello, World!
25 |
26 |
27 |
28 |
31 |
32 |
33 | Deferred Composition
34 |
35 |
36 |
37 | Devel says "Hello!" to
38 | World!
39 |
40 |
41 |
42 |
43 |
44 | Owen says "Hello!" to
45 | World!
46 |
47 |
48 |
49 | The two speakers are named -
50 | and -.
51 |
52 |
53 |
54 |
57 |
58 |
59 |
Deferred <is-land>
60 |
61 |
62 | Click to hydrate!
63 |
64 |
65 | Hello, World!
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | Narrow viewport to hydrate!
75 |
76 |
77 | Hello, World!
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/src/demo/hydration/is-land.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `deferred-comp` tests
5 |
6 |
7 |
8 |
9 |
10 |
11 | Hello, World!
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/demo/hydration/is-land.test.ts:
--------------------------------------------------------------------------------
1 | import '@11ty/is-land';
2 |
3 | import { DeferredComp } from './deferred-comp.js';
4 | import { isHydrated } from 'hydroactive';
5 | import { testCase, useTestCases } from '../../testing/test-cases.js';
6 |
7 | describe('is-land', () => {
8 | describe('Island', () => {
9 | useTestCases();
10 |
11 | it('hydrates deferred children', testCase('deferred', async (island) => {
12 | DeferredComp.define();
13 | const deferred = island.querySelector('deferred-comp')!;
14 | expect(isHydrated(deferred)).toBeFalse();
15 |
16 | deferred.click();
17 |
18 | // `` hydrates asynchronously, no easy way to await it. Seems to
19 | // work on microtask scheduling so waiting one macrotask should be
20 | // sufficient.
21 | await new Promise((resolve) => {
22 | setTimeout(() => { resolve(); });
23 | });
24 |
25 | expect(isHydrated(deferred)).toBeTrue();
26 | expect(deferred).toBeInstanceOf(DeferredComp);
27 |
28 | await deferred.stable();
29 |
30 | expect(deferred.querySelector('span')!.textContent!).toBe('HydroActive');
31 | }));
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | HydroActive Demo
5 |
6 |
7 |
8 | HydroActive Demo
9 |
10 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/demo/reactivity/cached-value.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `cached-value` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/reactivity/cached-value.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { CachedValue } from './cached-value.js';
3 |
4 | describe('cached-value', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function render({ count }: { count: number }) {
10 | return parseHtml(CachedValue, `
11 |
12 | The current count is: ${count}.
13 | Pi computed to that many decimal places is:
14 | ${computePiWithPrecision(count)}.
15 |
16 |
17 | Pi is still:
18 | ${computePiWithPrecision(count)}.
19 |
20 |
21 |
22 | `);
23 | }
24 |
25 | function computePiWithPrecision(precision: number): string {
26 | const length = '3.'.length + precision;
27 | return Math.PI.toFixed(48).slice(0, length).padEnd(length, '0');
28 | }
29 |
30 | describe('CachedValue', () => {
31 | it('does not re-render on hydration', async () => {
32 | const el = render({ count: 5 });
33 | document.body.appendChild(el);
34 |
35 | await el.stable();
36 |
37 | expect(el.querySelector('#count')!.textContent).toBe('5');
38 | expect(el.querySelector('#pi')!.textContent).toBe('3.14159');
39 | expect(el.querySelector('#pi-again')!.textContent).toBe('3.14159');
40 | });
41 |
42 | it('increments on click', async () => {
43 | const el = render({ count: 5 });
44 | document.body.appendChild(el);
45 |
46 | (el.querySelector('button')! as HTMLElement).click();
47 |
48 | await el.stable();
49 |
50 | expect(el.querySelector('#count')!.textContent).toBe('6');
51 | expect(el.querySelector('#pi')!.textContent).toBe('3.141592');
52 | expect(el.querySelector('#pi-again')!.textContent).toBe('3.141592');
53 | });
54 |
55 | it('reuses the PI computation', async () => {
56 | const consoleSpy = spyOn(console, 'log');
57 |
58 | const el = render({ count: 5 });
59 | document.body.appendChild(el);
60 |
61 | await el.stable();
62 |
63 | expect(consoleSpy)
64 | .toHaveBeenCalledOnceWith('Computing PI to 5 digits...');
65 | consoleSpy.calls.reset();
66 |
67 | (el.querySelector('button')! as HTMLElement).click();
68 | await el.stable();
69 |
70 | expect(consoleSpy)
71 | .toHaveBeenCalledOnceWith('Computing PI to 6 digits...');
72 | });
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/src/demo/reactivity/cached-value.ts:
--------------------------------------------------------------------------------
1 | import { component } from 'hydroactive';
2 | import { bind, live } from 'hydroactive/signal-accessors.js';
3 | import { cached } from 'hydroactive/signals.js';
4 |
5 | /** Uses `cached` to avoid repeatedly executing an expensive computed signal. */
6 | export const CachedValue = component('cached-value', (host) => {
7 | const count = live(host.query('#count').access(), host, Number);
8 | host.query('button').access().listen(host, 'click', () => {
9 | count.set(count() + 1);
10 | });
11 |
12 | // Define a computed with `cached` to cache the result. No matter how many
13 | // times `pi()` is called, the result will be reused as long as `count` does
14 | // not change.
15 | const pi = cached(() => {
16 | console.log(`Computing PI to ${count()} digits...`);
17 | return computePiWithPrecision(count());
18 | });
19 |
20 | // `pi` is read twice here, and both will update automatically, but it will
21 | // only be computed once.
22 | bind(host.query('#pi').access(), host, String, () => pi());
23 | bind(host.query('#pi-again').access(), host, String, () => pi());
24 | });
25 |
26 | CachedValue.define();
27 |
28 | declare global {
29 | interface HTMLElementTagNameMap {
30 | 'cached-value': InstanceType;
31 | }
32 | }
33 |
34 | /**
35 | * Pretend this is computationally expensive and we don't want to run it more
36 | * than we need to.
37 | */
38 | function computePiWithPrecision(precision: number): string {
39 | const length = '3.'.length + precision;
40 | return Math.PI.toFixed(48).slice(0, length).padEnd(length, '0');
41 | }
42 |
--------------------------------------------------------------------------------
/src/demo/reactivity/computed-value.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `computed-value` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/reactivity/computed-value.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { ComputedValue } from './computed-value.js';
3 |
4 | describe('computed-value', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function render({ count }: { count: number }) {
10 | return parseHtml(ComputedValue, `
11 |
12 | The current count is: ${count}.
13 | The negative count is: ${-count}.
14 |
15 |
16 | `);
17 | }
18 |
19 | describe('ComputedValue', () => {
20 | it('does not re-render on hydration', async () => {
21 | const el = render({ count: 5 });
22 | document.body.appendChild(el);
23 |
24 | await el.stable();
25 |
26 | expect(el.querySelector('#count')!.textContent).toBe('5');
27 | expect(el.querySelector('#negative')!.textContent).toBe('-5');
28 | });
29 |
30 | it('increments on click', async () => {
31 | const el = render({ count: 5 });
32 | document.body.appendChild(el);
33 |
34 | (el.querySelector('button')! as HTMLElement).click();
35 |
36 | await el.stable();
37 |
38 | expect(el.querySelector('#count')!.textContent).toBe('6');
39 | expect(el.querySelector('#negative')!.textContent).toBe('-6');
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/demo/reactivity/computed-value.ts:
--------------------------------------------------------------------------------
1 | import { component } from 'hydroactive';
2 | import { bind, live } from 'hydroactive/signal-accessors.js';
3 | import { Signal } from 'hydroactive/signals.js';
4 |
5 | /** Displays a value computed from another value in the DOM. */
6 | export const ComputedValue = component('computed-value', (host) => {
7 | // Create a signal for the real underlying value.
8 | const count = live(host.query('#count').access(), host, Number);
9 |
10 | // Create a computed signal with a function wrapper that computes the negative
11 | // of the count.
12 | const negative: Signal = () => -count();
13 |
14 | // Bind the negative version of the count to the negative label.
15 | bind(host.query('#negative').access(), host, Number, () => negative());
16 |
17 | host.query('button').access().listen(host, 'click', () => {
18 | count.set(count() + 1);
19 | });
20 | });
21 |
22 | ComputedValue.define();
23 |
24 | declare global {
25 | interface HTMLElementTagNameMap {
26 | 'computed-value': InstanceType;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/demo/reactivity/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Reactivity Demo
5 |
6 |
7 |
15 |
16 |
17 | Reactivity Demo
18 |
19 |
20 |
21 |
22 | Computed Value
23 |
24 | The current count is: 5.
25 | The negative count is: -5.
26 |
27 |
28 |
29 |
30 |
31 |
32 | Cached Value
33 |
34 | The current count is: 10.
35 | Pi computed to that many decimal places is:
36 | 3.141592653.
37 |
38 |
39 | Pi is still: 3.141592653.
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | Signal Effect
48 |
49 | The current count is: 15.
50 |
51 | The count has been updated: 0 times.
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/demo/reactivity/signal-effect.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `signal-effect` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/reactivity/signal-effect.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { SignalEffect } from './signal-effect.js';
3 |
4 | describe('signal-effect', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | function render({ count }: { count: number }) {
10 | return parseHtml(SignalEffect, `
11 |
12 | The current count is: ${count}.
13 |
14 | The count has been updated: 0 times.
15 |
16 |
17 |
18 | `);
19 | }
20 |
21 | describe('SignalEffect', () => {
22 | it('updates once on hydration', async () => {
23 | const el = render({ count: 5 });
24 | document.body.appendChild(el);
25 |
26 | await el.stable();
27 |
28 | expect(el.querySelector('#count')!.textContent).toBe('5');
29 | expect(el.querySelector('#updates')!.textContent).toBe('1');
30 | });
31 |
32 | it('increments on click', async () => {
33 | const el = render({ count: 5 });
34 | document.body.appendChild(el);
35 | await el.stable(); // Update after hydration.
36 |
37 | (el.querySelector('button')! as HTMLElement).click();
38 | await el.stable(); // Update after click.
39 |
40 | expect(el.querySelector('#count')!.textContent).toBe('6');
41 | expect(el.querySelector('#updates')!.textContent).toBe('2');
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/demo/reactivity/signal-effect.ts:
--------------------------------------------------------------------------------
1 | import { component } from 'hydroactive';
2 | import { signal } from 'hydroactive/signals.js';
3 |
4 | /** Creates a side effect from a signal. */
5 | export const SignalEffect = component('signal-effect', (host) => {
6 | const countLabel = host.query('#count').access();
7 | const initial = countLabel.read(Number);
8 | const count = signal(initial);
9 | host.query('button').access().listen(host, 'click', () => {
10 | count.set(count() + 1);
11 | });
12 |
13 | // Track how many times the count has been updated.
14 | let updated = 0;
15 | const updatesLabel = host.query('#updates').access();
16 |
17 | // Create a side effect whenever `count` is modified.
18 | host.effect(() => {
19 | // Update the count label in the effect. Calling `count()` inside the effect
20 | // creates a dependency on `count`. Anytime `count` changes in the future,
21 | // this effect will be automatically re-run.
22 | countLabel.write(count(), Number);
23 |
24 | // Track each time the effect is executed and display it in the DOM. This is
25 | // the "side effect" of updating the count.
26 | updated++;
27 | updatesLabel.write(updated, Number);
28 | });
29 | });
30 |
31 | SignalEffect.define();
32 |
33 | declare global {
34 | interface HTMLElementTagNameMap {
35 | 'signal-effect': InstanceType;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/demo/shadow/closed-shadow.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `closed-shadow` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/shadow/closed-shadow.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { ClosedShadow } from './closed-shadow.js';
3 |
4 | describe('closed-shadow', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | describe('ClosedShadow', () => {
10 | function render(): InstanceType {
11 | return parseHtml(ClosedShadow, `
12 |
13 |
14 |
17 |
18 | Closed Shadow
19 |
20 | Hello
21 |
22 |
23 |
24 | Goodbye
25 |
26 | `);
27 | }
28 |
29 | it('updates the light and shadow DOM', async () => {
30 | // The shadow root is closed, so it is not accessible at
31 | // `Element.prototype.shadowRoot`. Instead, spy on
32 | // `HTMLElement.prototype.attachInternals` to extract the
33 | // `ElementInternals` object containing the shadow DOM.
34 | let internals!: ElementInternals;
35 | const attachInternals = HTMLElement.prototype.attachInternals;
36 | spyOn(HTMLElement.prototype, 'attachInternals').and.callFake(
37 | function (this: HTMLElement, ...args: Parameters) {
38 | internals = attachInternals.call(this, ...args);
39 | return internals;
40 | },
41 | );
42 |
43 | const el = render();
44 | document.body.append(el);
45 |
46 | await el.stable();
47 |
48 | const shadowDiv = internals.shadowRoot!.querySelector('div')!;
49 | expect(shadowDiv.textContent).toBe('I\'m blue,');
50 |
51 | const lightDiv = el.querySelector('div')!;
52 | expect(lightDiv.textContent).toBe('Da ba dee da ba di...');
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/demo/shadow/closed-shadow.ts:
--------------------------------------------------------------------------------
1 | import { baseComponent } from 'hydroactive';
2 |
3 | /** Accesses the shadow DOM with `host.shadow`. */
4 | export const ClosedShadow = baseComponent('closed-shadow', (host) => {
5 | // Query the shadow DOM under `host.shadow`.
6 | host.shadow.query('div').access().write('I\'m blue,', String);
7 |
8 | // Query the light DOM under `host`.
9 | host.query('div').access().write('Da ba dee da ba di...', String);
10 | });
11 |
12 | ClosedShadow.define();
13 |
14 | declare global {
15 | interface HTMLElementTagNameMap {
16 | 'closed-shadow': InstanceType;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/demo/shadow/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Shadow DOM Demo
5 |
6 |
7 |
15 |
16 |
17 | Shadow DOM Demo
18 |
19 |
20 |
21 |
22 |
23 |
26 |
27 | Open Shadow
28 |
29 | Hello
30 |
31 |
32 |
33 |
34 |
35 | Goodbye
36 |
37 |
38 |
39 |
40 |
43 |
44 | Closed Shadow
45 |
46 | Hello
47 |
48 |
49 |
50 |
51 |
52 | Goodbye
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/src/demo/shadow/open-shadow.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `open-shadow` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/demo/shadow/open-shadow.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from 'hydroactive/testing.js';
2 | import { OpenShadow } from './open-shadow.js';
3 |
4 | describe('open-shadow', () => {
5 | afterEach(() => {
6 | for (const node of document.body.childNodes) node.remove();
7 | });
8 |
9 | describe('OpenShadow', () => {
10 | function render(): InstanceType {
11 | return parseHtml(OpenShadow, `
12 |
13 |
14 |
17 |
18 | Open Shadow
19 |
20 | Hello
21 |
22 |
23 |
24 | Goodbye
25 |
26 | `);
27 | }
28 |
29 | it('updates the light and shadow DOM', async () => {
30 | const el = render();
31 | document.body.append(el);
32 |
33 | await el.stable();
34 |
35 | const shadowDiv = el.shadowRoot!.querySelector('div')!;
36 | expect(shadowDiv.textContent).toBe('I\'m red!');
37 |
38 | const lightDiv = el.querySelector('div')!;
39 | expect(lightDiv.textContent).toBe('I\'m not...');
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/demo/shadow/open-shadow.ts:
--------------------------------------------------------------------------------
1 | import { baseComponent } from 'hydroactive';
2 |
3 | /** Accesses the shadow DOM with `host.shadow`. */
4 | export const OpenShadow = baseComponent('open-shadow', (host) => {
5 | // Query the shadow DOM under `host.shadow`.
6 | host.shadow.query('div').access().write('I\'m red!', String);
7 |
8 | // Query the light DOM under `host`.
9 | host.query('div').access().write('I\'m not...', String);
10 | });
11 |
12 | OpenShadow.define();
13 |
14 | declare global {
15 | interface HTMLElementTagNameMap {
16 | 'open-shadow': InstanceType;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/element-accessor.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `element-accessor` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/hydration.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `hydration` tests
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/hydroactive-component.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `hydroactive-component` tests
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/hydroactive-component.ts:
--------------------------------------------------------------------------------
1 | import { Connectable, Connector } from './connectable.js';
2 | import { StabilityTracker } from './signals/schedulers/stability-tracker.js';
3 | import { UiScheduler } from './signals/schedulers/ui-scheduler.js';
4 |
5 | /**
6 | * A map of {@link HydroActiveComponent} elements to their associated
7 | * {@link ElementInternals}. This mapping can be imported internally within
8 | * HydroActive without making the internals accessible outside the component
9 | * (such as on an `_internals` property).
10 | *
11 | * We really only care about a closed shadow root on this type, however a closed
12 | * shadow root can be attached at any time, not just on custom element
13 | * construction. Therefore, we keep a reference to the entire
14 | * {@link ElementInternals} object in case a closed shadow root is attached in
15 | * the future and appears on this object.
16 | */
17 | const internalsMap =
18 | new WeakMap();
19 |
20 | /** Export the type as readonly so no one else messes with the contents. */
21 | export const elementInternalsMap =
22 | internalsMap as ReadonlyWeakMap;
23 | export type ReadonlyWeakMap =
24 | Pick, 'get' | 'has'>;
25 |
26 | /** Abstract base class for all HydroActive components. */
27 | export abstract class HydroActiveComponent extends HTMLElement {
28 | /** Whether or not the component has been hydrated. */
29 | #hydrated = false;
30 |
31 | protected _tracker = StabilityTracker.from();
32 | protected _defaultScheduler = UiScheduler.from();
33 |
34 | constructor() {
35 | super();
36 |
37 | internalsMap.set(this, this.attachInternals());
38 | }
39 |
40 | #connector = Connector.from(() => this.isConnected);
41 | public /* internal */ get _connectable(): Connectable {
42 | return this.#connector;
43 | }
44 |
45 | /** User-defined lifecycle hook invoked on hydration. */
46 | protected abstract hydrate(): ComponentDefinition | void;
47 |
48 | connectedCallback(): void {
49 | this.#connector.connect();
50 |
51 | this.#requestHydration();
52 | }
53 |
54 | disconnectedCallback(): void {
55 | this.#connector.disconnect();
56 | }
57 |
58 | // Trigger hydration when the `defer-hydration` attribute is removed.
59 | static get observedAttributes(): string[] { return ['defer-hydration']; }
60 | attributeChangedCallback(
61 | name: string,
62 | _oldValue: string | null,
63 | newValue: string | null,
64 | ): void {
65 | if (name === 'defer-hydration' && newValue === null) {
66 | this.#requestHydration();
67 | }
68 | }
69 |
70 | /** Hydrates the component if not already hydrated. Otherwise does nothing. */
71 | #requestHydration(): void {
72 | if (this.#hydrated) return;
73 | if (this.hasAttribute('defer-hydration')) return;
74 |
75 | this.#hydrated = true;
76 | this.hydrate();
77 | }
78 |
79 | /**
80 | * Returns a {@link Promise} which resolves when this component is stable. A
81 | * component is "stable" when there are no pending DOM operations scheduled.
82 | *
83 | * @returns A {@link Promise} which resolves when this component is stable.
84 | */
85 | public async stable(): Promise {
86 | return await this._tracker.stable();
87 | }
88 | }
89 |
90 | /** The properties to apply to a component after hydration. */
91 | export type ComponentDefinition = Record;
92 |
93 | /**
94 | * Applies the given {@link ComponentDefinition} to the provided {@link comp} by
95 | * assigning all its properties.
96 | *
97 | * @throws when the component has already defined a property specified in the
98 | * definition.
99 | */
100 | export function applyDefinition(
101 | comp: HydroActiveComponent,
102 | compDef: CompDef,
103 | ): asserts comp is HydroActiveComponent & CompDef {
104 | for (const [key, value] of Object.entries(compDef)) {
105 | if (key in comp) {
106 | throw new Error(`Cannot redefine existing property \`${key}\`.`);
107 | }
108 |
109 | (comp as unknown as Record)[key] = value;
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { AttrAccessor } from './attribute-accessor.js';
2 | export { type BaseHydrateLifecycle, baseComponent } from './base-component.js';
3 | export { type SignalHydrateLifecycle, signalComponent as component } from './signal-component.js';
4 | export { type Connectable, type OnConnect, type OnDisconnect } from './connectable.js';
5 | export { Dehydrated } from './dehydrated.js';
6 | export { ElementAccessor } from './element-accessor.js';
7 | export { type Queryable } from './queryable.js';
8 | export { QueryRoot } from './query-root.js';
9 | export { hydrate, isHydrated } from './hydration.js';
10 | export { type HydroActiveComponent } from './hydroactive-component.js';
11 |
12 | // Only export the `ComponentAccessor` object types because consumers should not
13 | // construct these objects directly as doing so would leak internal details
14 | // about components such as a closed shadow root.
15 | export { type ComponentAccessor } from './component-accessor.js';
16 | export { type SignalComponentAccessor } from './signal-component-accessor.js';
17 |
--------------------------------------------------------------------------------
/src/query-root.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `query-root` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/query.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `query` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/queryable.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `queryable` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/queryable.test.ts:
--------------------------------------------------------------------------------
1 | import { Queryable } from './queryable.js';
2 |
3 | describe('queryable', () => {
4 | describe('Queryable', () => {
5 | describe('query', () => {
6 | it('type checks the result by parsing the selector query', () => {
7 | // Type only test, only needs to compile, not execute.
8 | expect().nothing();
9 | () => {
10 | const queryable = {} as Queryable;
11 | const result = queryable.query('.foo #bar > [baz] input');
12 |
13 | result satisfies Queryable;
14 | };
15 | });
16 |
17 | it('returns a non-nullish value by default', () => {
18 | // Type only test, only needs to compile, not execute.
19 | expect().nothing();
20 | () => {
21 | const queryable = {} as Queryable;
22 | const result = queryable.query('input');
23 |
24 | // @ts-expect-error
25 | null satisfies typeof result;
26 | };
27 | });
28 |
29 | it('returns a non-nullish value when explicitly not optional', () => {
30 | // Type only test, only needs to compile, not execute.
31 | expect().nothing();
32 | () => {
33 | const queryable = {} as Queryable;
34 | const result = queryable.query('input', { optional: false });
35 |
36 | // @ts-expect-error
37 | null satisfies typeof result;
38 | };
39 | });
40 |
41 | it('returns a nullish value when explicitly optional', () => {
42 | // Type only test, only needs to compile, not execute.
43 | expect().nothing();
44 | () => {
45 | const queryable = {} as Queryable;
46 | const result = queryable.query('input', { optional: true });
47 |
48 | null satisfies typeof result;
49 | };
50 | });
51 |
52 | it('returns a nullish value when optionality is unknown', () => {
53 | // Type only test, only needs to compile, not execute.
54 | expect().nothing();
55 | () => {
56 | const queryable = {} as Queryable;
57 | const optional = false as boolean;
58 | const result = queryable.query('input', { optional });
59 |
60 | null satisfies typeof result;
61 | };
62 | });
63 |
64 | it('can query a shadow root', () => {
65 | // Type only test, only needs to compile, not execute.
66 | expect().nothing();
67 | () => {
68 | const queryable = {} as Queryable;
69 | const result = queryable.query('input');
70 |
71 | result satisfies Queryable;
72 | };
73 | });
74 | });
75 |
76 | describe('queryAll', () => {
77 | it('type checks the result by parsing the selector query', () => {
78 | // Type only test, only needs to compile, not execute.
79 | expect().nothing();
80 | () => {
81 | const queryable = {} as Queryable;
82 | const result = queryable.queryAll('.foo #bar > [baz] input');
83 |
84 | result satisfies Array>;
85 | };
86 | });
87 |
88 | it('can query a shadow root', () => {
89 | // Type only test, only needs to compile, not execute.
90 | expect().nothing();
91 | () => {
92 | const queryable = {} as Queryable;
93 | const result = queryable.queryAll('input');
94 |
95 | result satisfies Array>;
96 | };
97 | });
98 | });
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/src/queryable.ts:
--------------------------------------------------------------------------------
1 | import { QueriedElement } from './query.js';
2 |
3 | /**
4 | * Represents an element or shadow root which can query its descendants for
5 | * those matching a provided CSS selector.
6 | */
7 | export interface Queryable {
8 | /**
9 | * Queries light DOM descendants for the provided selector and returns the
10 | * first matching element wrapped in a {@link Queryable}.
11 | *
12 | * @param selector The selector to query for.
13 | * @param options Additional options for the query.
14 | * `optional` specifies what happens when an element is not found. If
15 | * `optional` is `false` (default), an error is thrown. If `optional`
16 | * is `true`, then `null` is returned.
17 | * @returns A {@link Queryable} which wraps the query result. Returns `null`
18 | * if `optional` is `true` and no element is found.
19 | * @throws If no element is found and `optional` is `false` (default).
20 | */
21 | query(
22 | selector: Query,
23 | options?: { readonly optional?: false },
24 | ): QueryResult;
25 | query(
26 | selector: Query,
27 | options?: { readonly optional?: boolean },
28 | ): QueryResult | null;
29 | query(selector: Query, options?: {
30 | readonly optional?: boolean,
31 | }): QueryResult | null;
32 |
33 | /**
34 | * Queries light DOM descendants for the provided selector and returns all
35 | * matching elements, each wrapped in an {@link Queryable}. Always returns a
36 | * real {@link Array}, not a {@link NodeListOf} like
37 | * {@link Element.prototype.querySelectorAll}.
38 | *
39 | * @param selector The selector to query for.
40 | * @param options Additional options for the query.
41 | * `optional` specifies what happens when no elements are found. If
42 | * `optional` is `false` (default), an error is thrown. If `optional`
43 | * is `true`, then an empty array is returned.
44 | * @returns An {@link Array} of the queried elements, each wrapped in an
45 | * {@link Queryable}.
46 | * @throws If no element is found and `optional` is `false` (default).
47 | */
48 | queryAll(
49 | selector: Selector,
50 | options?: { optional?: boolean },
51 | ): Array>>;
52 |
53 | /**
54 | * Returns the root element's {@link ShadowRoot} wrapped in a
55 | * {@link Queryable}. This allows subsequent queries to be scoped to the
56 | * {@link ShadowRoot}.
57 | *
58 | * @returns A {@link Queryable} wrapping the {@link ShadowRoot} which of the
59 | * root element.
60 | * @throws If this element does not have a shadow root or if the shadow root
61 | * is closed and not provided to the {@link Queryable}.
62 | */
63 | get shadow(): Queryable;
64 | }
65 |
66 | // `QueriedElement` returns `null` when given a pseudo-element selector. Need to
67 | // avoid boxing this `null` into `Queryable`.
68 | type QueryResult =
69 | QueriedElement extends null
70 | ? null
71 | : Queryable>
72 | ;
73 |
74 | // `QueriedElement` returns `null` when given a pseudo-element selector. Need to
75 | // avoid boxing this `null` into `null[]`, when any such values would be
76 | // filtered out of the result.
77 | type QueryAllResult =
78 | QueriedElement extends null
79 | ? Element
80 | : QueriedElement
81 | ;
82 |
--------------------------------------------------------------------------------
/src/serializer-tokens.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `serializer-tokens` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/serializer-tokens.ts:
--------------------------------------------------------------------------------
1 | import { ElementSerializer, ElementSerializable, AttrSerializer, AttrSerializable, stringSerializer, numberSerializer, booleanSerializer, bigintSerializer, toSerializer } from './serializers.js';
2 |
3 | // Tokens which reference `Serializer` objects for primitive types, filtered
4 | // down only to those which extend the given input type.
5 | type PrimitiveSerializerToken =
6 | | Value extends string ? typeof String : never
7 | | Value extends number ? typeof Number : never
8 | | Value extends boolean ? typeof Boolean : never
9 | | Value extends bigint ? typeof BigInt : never
10 | ;
11 |
12 | /**
13 | * Tokens which can be exchanged for an {@link ElementSerializer} object.
14 | * {@link ElementSerializer} objects are treated as tokens which can be
15 | * exchanged for themselves.
16 | */
17 | export type ElementSerializerToken =
18 | | PrimitiveSerializerToken
19 | | ElementSerializer
20 | | ElementSerializable
21 | ;
22 |
23 | /**
24 | * Tokens which can be exchanged for an {@link AttrSerializer} object.
25 | * {@link AttrSerializer} objects are treated as tokens which can be exchanged
26 | * for themselves.
27 | */
28 | export type AttrSerializerToken =
29 | | PrimitiveSerializerToken
30 | | AttrSerializer
31 | | AttrSerializable
32 | ;
33 |
34 | /**
35 | * A token for either an {@link ElementSerializer} or an {@link AttrSerializer}.
36 | */
37 | export type SerializerToken =
38 | ElementSerializerToken | AttrSerializerToken;
39 |
40 | /**
41 | * Resolves and returns the {@link ElementSerializer} or {@link AttrSerializer}
42 | * referenced by the provided token. Token literals should be statically
43 | * analyzable enough for the type system to compute the actual return type of
44 | * this function and use it for type inference.
45 | */
46 | export function resolveSerializer<
47 | Token extends SerializerToken,
48 | SerializerKind extends
49 | | ElementSerializer
50 | | AttrSerializer,
51 | SerializableKind extends
52 | | ElementSerializable
53 | | AttrSerializable,
54 | >(token: Token): ResolveSerializer<
55 | Token,
56 | SerializerKind,
57 | SerializableKind
58 | > {
59 | switch (token) {
60 | case String: {
61 | return stringSerializer as
62 | ResolveSerializer;
63 | } case Number: {
64 | return numberSerializer as
65 | ResolveSerializer;
66 | } case Boolean: {
67 | return booleanSerializer as
68 | ResolveSerializer;
69 | } case BigInt: {
70 | return bigintSerializer as
71 | ResolveSerializer;
72 | } default: {
73 | if (toSerializer in token) {
74 | type Serializable =
75 | | ElementSerializable
76 | | AttrSerializable
77 | ;
78 | return (token as Serializable)[toSerializer]() as
79 | ResolveSerializer;
80 | } else {
81 | // Already a serializer.
82 | return token as
83 | ResolveSerializer;
84 | }
85 | }
86 | }
87 | }
88 |
89 | /**
90 | * Computes the return type of a resolved {@link ElementSerializer} or
91 | * {@link AttrSerializer} object for a given token.
92 | *
93 | * @param Token The {@link SerializerToken} to resolve.
94 | * @param SerializerKind The kind of serializer to accept. This *must* be either
95 | * `ElementSerializer` or `AttrSerializer`. Do *not*
96 | * use other generics such as `ElementSerializer`, as this
97 | * would exclude valid serializers due to contra-variance requirements on
98 | * the serializer type.
99 | * @param SerializerKind The kind of serializable to accept. This *must* be
100 | * either `ElementSerializer` or `AttrSerializer`. Do
101 | * *not* use other generics such as `ElementSerializer`,
102 | * as this would exclude valid serializers due to contra-variance
103 | * requirements on the serializer type.
104 | * @returns The resolved {@link Serializer} type, or `never` if none is found.
105 | */
106 | export type ResolveSerializer<
107 | // The token to resolve.
108 | Token extends SerializerToken,
109 | // Whether to resolve to element or attribute serializers.
110 | SerializerKind extends
111 | | ElementSerializer
112 | | AttrSerializer,
113 | // Whether to resolve to element or attribute serializables.
114 | SerializableKind extends
115 | | ElementSerializable
116 | | AttrSerializable,
117 | > = Token extends SerializableKind
118 | ? ReturnType
119 | : Token extends SerializerKind
120 | ? Token
121 | : Token extends typeof String
122 | ? typeof stringSerializer
123 | : Token extends typeof Number
124 | ? typeof numberSerializer
125 | : Token extends typeof Boolean
126 | ? typeof booleanSerializer
127 | : Token extends typeof BigInt
128 | ? typeof bigintSerializer
129 | : never
130 | ;
131 |
--------------------------------------------------------------------------------
/src/serializers.ts:
--------------------------------------------------------------------------------
1 | export * from './serializers/index.js';
2 |
--------------------------------------------------------------------------------
/src/serializers/index.ts:
--------------------------------------------------------------------------------
1 | export { type AttrSerializable, type AttrSerializer, type ElementSerializable, type ElementSerializer, type Serialized, toSerializer } from './serializer.js';
2 | export { type JsonValue, jsonSerializer as json } from './json-serializer.js';
3 | export { bigintSerializer, booleanSerializer, numberSerializer, stringSerializer } from './primitive-serializers.js';
4 |
--------------------------------------------------------------------------------
/src/serializers/json-serializer.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `json-serializer` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/serializers/json-serializer.test.ts:
--------------------------------------------------------------------------------
1 | import { jsonSerializer } from './json-serializer.js';
2 |
3 | describe('json-serializer', () => {
4 | describe('jsonSerializer', () => {
5 | it('serializes JSON', () => {
6 | expect(jsonSerializer.serialize({ foo: 'bar' } as any))
7 | .toBe(JSON.stringify({ foo: 'bar' }));
8 | });
9 |
10 | it('deserializes JSON', () => {
11 | expect(jsonSerializer.deserialize('{ "foo": "bar" }'))
12 | .toEqual({ foo: 'bar' });
13 | });
14 |
15 | it('serializes JSON to element text content', () => {
16 | const el = document.createElement('div');
17 |
18 | jsonSerializer.serializeTo({ foo: 'bar' } as any, el);
19 |
20 | expect(el.textContent!).toBe(JSON.stringify({ foo: 'bar' }));
21 | });
22 |
23 | it('deserializes JSON from element text content', () => {
24 | const el = document.createElement('div');
25 | el.textContent = '{ "foo": "bar" }';
26 |
27 | expect(jsonSerializer.deserializeFrom(el)).toEqual({ foo: 'bar' });
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/serializers/json-serializer.ts:
--------------------------------------------------------------------------------
1 | import { TextContentSerializer } from './primitive-serializers.js';
2 |
3 | /** A single value in a JSON expression. */
4 | export type JsonValue =
5 | | string
6 | | boolean
7 | | number
8 | | null
9 | | JsonArray
10 | | { [prop: string]: JsonValue }
11 | ;
12 | // An explicit interface is necessary to avoid an infinitely recursive type.
13 | interface JsonArray extends Array {}
14 |
15 | /**
16 | * Serializes JSON content using {@link JSON.parse} and {@link JSON.stringify}.
17 | * See those functions to understand edge-case behavior.
18 | *
19 | * Note that `JSON.parse('null')` does return `null`. Same for `'true'`,
20 | * `'"test"'`, and `'123'`. The same behavior applies to `deserialize`.
21 | */
22 | export const jsonSerializer =
23 | new class extends TextContentSerializer {
24 | public override serialize(value: JsonValue): string {
25 | return JSON.stringify(value);
26 | }
27 |
28 | public override deserialize(serial: string): JsonValue {
29 | return JSON.parse(serial) as JsonValue;
30 | }
31 | }();
32 |
--------------------------------------------------------------------------------
/src/serializers/primitive-serializers.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `primitive-serializers` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/serializers/primitive-serializers.test.ts:
--------------------------------------------------------------------------------
1 | import { bigintSerializer, booleanSerializer, numberSerializer, stringSerializer } from './primitive-serializers.js';
2 |
3 | describe('primitive-serializers', () => {
4 | describe('booleanSerializer', () => {
5 | it('deserializes booleans', () => {
6 | expect(booleanSerializer.deserialize('true')).toBeTrue();
7 | expect(booleanSerializer.deserialize('false')).toBeFalse();
8 | });
9 |
10 | it('throws an error when deserializing any non-boolean value', () => {
11 | expect(() => booleanSerializer.deserialize('')).toThrowError();
12 | expect(() => booleanSerializer.deserialize('not-true')).toThrowError();
13 | expect(() => booleanSerializer.deserialize('TRUE')).toThrowError();
14 | expect(() => booleanSerializer.deserialize('FALSE')).toThrowError();
15 | });
16 |
17 | it('serializes booleans', () => {
18 | expect(booleanSerializer.serialize(true)).toBe('true');
19 | expect(booleanSerializer.serialize(false)).toBe('false');
20 | });
21 |
22 | it('deserializes a boolean from element text content', () => {
23 | const trueEl = document.createElement('div');
24 | trueEl.textContent = 'true';
25 | expect(booleanSerializer.deserializeFrom(trueEl)).toBeTrue();
26 |
27 | const falseEl = document.createElement('div');
28 | falseEl.textContent = 'false';
29 | expect(booleanSerializer.deserializeFrom(falseEl)).toBeFalse();
30 | });
31 |
32 | it('throws an error when deserializing any non-boolean value from element text content', () => {
33 | const el = document.createElement('div');
34 |
35 | el.textContent = '';
36 | expect(() => booleanSerializer.deserializeFrom(el)).toThrowError();
37 |
38 | el.textContent = 'not-true';
39 | expect(() => booleanSerializer.deserializeFrom(el)).toThrowError();
40 |
41 | el.textContent = 'TRUE';
42 | expect(() => booleanSerializer.deserializeFrom(el)).toThrowError();
43 |
44 | el.textContent = 'FALSE';
45 | expect(() => booleanSerializer.deserializeFrom(el)).toThrowError();
46 | });
47 |
48 | it('serializes a boolean to element text content', () => {
49 | const el = document.createElement('div');
50 |
51 | booleanSerializer.serializeTo(true, el)
52 | expect(el.textContent!).toBe('true');
53 |
54 | booleanSerializer.serializeTo(false, el);
55 | expect(el.textContent!).toBe('false');
56 | });
57 | });
58 |
59 | describe('numberSerializer', () => {
60 | it('deserializes numbers', () => {
61 | expect(numberSerializer.deserialize('1')).toBe(1);
62 | });
63 |
64 | it('serializes numbers', () => {
65 | expect(numberSerializer.serialize(1)).toBe('1');
66 | });
67 |
68 | it('deserializes numbers from element text content', () => {
69 | const el = document.createElement('div');
70 | el.textContent = '1';
71 |
72 | expect(numberSerializer.deserializeFrom(el)).toBe(1);
73 | });
74 |
75 | it('serializes numbers', () => {
76 | const el = document.createElement('div');
77 |
78 | numberSerializer.serializeTo(1, el);
79 |
80 | expect(el.textContent!).toBe('1');
81 | });
82 | });
83 |
84 | describe('bigintSerializer', () => {
85 | // One larger than the maximum integer representable with the basic
86 | // `Number` type.
87 | const largeInt = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
88 |
89 | it('deserializes bigints', () => {
90 | expect(bigintSerializer.deserialize(largeInt.toString())).toBe(largeInt);
91 | });
92 |
93 | it('serializes bigints', () => {
94 | expect(bigintSerializer.serialize(largeInt)).toBe(largeInt.toString());
95 | });
96 |
97 | it('deserializes bigints from element text content', () => {
98 | const el = document.createElement('div');
99 | el.textContent = largeInt.toString();
100 |
101 | expect(bigintSerializer.deserializeFrom(el)).toBe(largeInt);
102 | });
103 |
104 | it('serializes bigints to element text content', () => {
105 | const el = document.createElement('div');
106 |
107 | bigintSerializer.serializeTo(largeInt, el);
108 |
109 | expect(el.textContent!).toBe(largeInt.toString());
110 | });
111 | });
112 |
113 | describe('stringSerializer', () => {
114 | it('deserializes strings', () => {
115 | expect(stringSerializer.deserialize('test')).toBe('test');
116 | });
117 |
118 | it('serializes strings', () => {
119 | expect(stringSerializer.serialize('test')).toBe('test');
120 | });
121 |
122 | it('deserializes strings from element text content', () => {
123 | const el = document.createElement('div');
124 | el.textContent = 'test';
125 |
126 | expect(stringSerializer.deserializeFrom(el)).toBe('test');
127 | });
128 |
129 | it('serializes strings to element text content', () => {
130 | const el = document.createElement('div');
131 |
132 | stringSerializer.serializeTo('test', el);
133 |
134 | expect(el.textContent!).toBe('test');
135 | });
136 | });
137 | });
138 |
--------------------------------------------------------------------------------
/src/serializers/primitive-serializers.ts:
--------------------------------------------------------------------------------
1 | import { type AttrSerializer, type ElementSerializer } from './serializer.js';
2 |
3 | /**
4 | * Provides a basic implementation of `serializeTo` and `deserializeFrom` by
5 | * wrapping the implementations of `serialize` and `deserialize` with the
6 | * element's text content.
7 | *
8 | * @internal
9 | */
10 | export abstract class TextContentSerializer
11 | implements ElementSerializer, AttrSerializer {
12 | public serializeTo(value: Value, element: Element): void {
13 | element.textContent = this.serialize(value);
14 | }
15 |
16 | public deserializeFrom(element: Element): Value {
17 | return this.deserialize(element.textContent!);
18 | }
19 |
20 | public abstract serialize(value: Value): string;
21 | public abstract deserialize(serial: string): Value;
22 | }
23 |
24 | /**
25 | * Serializes `boolean` type as either "true" or "false", case sensitive. Throws
26 | * an error if any other string is deserialized.
27 | */
28 | export const booleanSerializer =
29 | new class extends TextContentSerializer {
30 | public override serialize(value: boolean): string {
31 | return value.toString();
32 | }
33 |
34 | public override deserialize(serial: string): boolean {
35 | switch (serial) {
36 | case 'false': return false;
37 | case 'true': return true;
38 | default: throw new Error(`Failed to deserialize to boolean:\n${serial}`);
39 | }
40 | }
41 | }();
42 |
43 | /**
44 | * Serializes `number` type using the {@link Number} constructor and
45 | * {@link Number.prototype.toString}. See those functions to understand
46 | * edge-case behavior such as `NaN`.
47 | */
48 | export const numberSerializer =
49 | new class extends TextContentSerializer {
50 | public override serialize(value: number): string {
51 | return value.toString();
52 | }
53 |
54 | public override deserialize(serial: string): number {
55 | return Number(serial);
56 | }
57 | }();
58 |
59 | /**
60 | * Serializes `bigint` type using the {@link BigInt} constructor and
61 | * {@link BigInt.prototype.toString}. See those functions to understand
62 | * edge-case behavior.
63 | */
64 | export const bigintSerializer =
65 | new class extends TextContentSerializer {
66 | public override serialize(value: bigint): string {
67 | return value.toString();
68 | }
69 |
70 | public override deserialize(serial: string): bigint {
71 | return BigInt(serial);
72 | }
73 | }();
74 |
75 | /**
76 | * Serializes `string` type. Actually a no-op since string are already strings.
77 | */
78 | export const stringSerializer =
79 | new class extends TextContentSerializer {
80 | public override serialize(value: string): string {
81 | return value;
82 | }
83 |
84 | public override deserialize(serial: string): string {
85 | return serial;
86 | }
87 | }();
88 |
--------------------------------------------------------------------------------
/src/serializers/serializer.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines types related to DOM serialization. */
2 |
3 | /**
4 | * Supports serializing and deserializing between `Value` and an element in the
5 | * DOM.
6 | *
7 | * Invariant: Any input `value` should be equivalent to the result of
8 | * serializing and deserializing it:
9 | *
10 | * ```typescript
11 | * const el = // ...
12 | * const oldValue: Value = // ...
13 | * serializer.serializeTo(oldValue, el);
14 | * const newValue: Value = serializer.deserializeFrom(el);
15 | *
16 | * // Should be true for some reasonable definition of `equals`.
17 | * equals(oldValue, newValue);
18 | * ```
19 | *
20 | * These values should be _equivalent_, not necessarily referentially equal
21 | * (`===`).
22 | *
23 | * No such invariant holds for `deserializeFrom` -> `serializeTo`, as the
24 | * serialized representation may be slightly different between equivalent
25 | * objects.
26 | */
27 | export interface ElementSerializer {
28 | /**
29 | * Serializes the value and applies it to the provided DOM element.
30 | *
31 | * @param value The value to serialize.
32 | * @param element The element to serialize to.
33 | */
34 | serializeTo(value: Value, element: El): void;
35 |
36 | /**
37 | * Deserializes the given element to a value.
38 | *
39 | * @param element The element to deserialize from.
40 | * @returns The deserialized value.
41 | */
42 | deserializeFrom(element: El): Value;
43 | }
44 |
45 | /**
46 | * Supports serializing and deserializing between `Value` and a string for use
47 | * in element attributes.
48 | *
49 | * Invariant: Any input `value` should be equivalent to the result of
50 | * serializing and deserializing it:
51 | *
52 | * ```typescript
53 | * const oldValue: Value = // ...
54 | * const serial: string = serializer.serialize(oldValue);
55 | * const newValue: Value = serializer.deserialize(serial);
56 | *
57 | * // Should be true for some reasonable definition of `equals`.
58 | * equals(oldValue, newValue);
59 | * ```
60 | *
61 | * These values should be _equivalent_, not necessarily referentially equal
62 | * (`===`).
63 | *
64 | * No such invariant holds for `deserialize` -> `serialize`, as the serialized
65 | * representation may be slightly different between equivalent objects.
66 | */
67 | export interface AttrSerializer {
68 | /**
69 | * Serializes the given value to a string.
70 | *
71 | * @param value The value to serialize.
72 | * @returns The serialized attribute text.
73 | */
74 | serialize(value: Value): string;
75 |
76 | /**
77 | * Deserializes the given text to a value.
78 | *
79 | * @param attr The attribute text to deserialize.
80 | * @returns The deserialized value.
81 | */
82 | deserialize(attr: string): Value;
83 | }
84 |
85 | /**
86 | * Returns the serialized type wrapped by the given {@link AttrSerializer} or
87 | * {@link AttrSerializable} type.
88 | */
89 | export type Serialized<
90 | Serial extends
91 | | ElementSerializer
92 | | ElementSerializable
93 | | AttrSerializer
94 | | AttrSerializable,
95 | > = Serial extends AttrSerializable
96 | ? Value
97 | : Serial extends ElementSerializable
98 | ? Value
99 | : Serial extends ElementSerializer
100 | ? Value
101 | : Serial extends AttrSerializer
102 | ? Value
103 | : never
104 | ;
105 |
106 | /**
107 | * A symbol which maps an object to a {@link AttrSerializer} which can serialize and
108 | * deserialize that type.
109 | */
110 | export const toSerializer = Symbol('toSerializer');
111 |
112 | /**
113 | * Serializable objects contain a {@link toSerializer} property which maps an
114 | * object to an {@link AttrSerializer} which can serialize and deserialize that
115 | * type.
116 | */
117 | export type ElementSerializable = {
118 | [toSerializer](): ElementSerializer,
119 | };
120 |
121 | /**
122 | * Serializable objects contain a {@link toSerializer} property which maps an
123 | * object to a {@link AttrSerializer} which can serialize and deserialize that type.
124 | */
125 | export type AttrSerializable = {
126 | [toSerializer](): AttrSerializer,
127 | };
128 |
--------------------------------------------------------------------------------
/src/signal-accessors.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `signal-accessors` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signal-accessors.ts:
--------------------------------------------------------------------------------
1 | import { ElementAccessor } from './element-accessor.js';
2 | import { ElementSerializerToken, ResolveSerializer, resolveSerializer } from './serializer-tokens.js';
3 | import { ElementSerializable, ElementSerializer, Serialized } from './serializers.js';
4 | import { ReactiveRoot, Signal, WriteableSignal, signal } from './signals.js';
5 |
6 | /** Elements whose text content is currently bound to a reactive signal. */
7 | const boundElements = new WeakSet();
8 |
9 | /**
10 | * Creates a {@link WriteableSignal} initialized with the current value of the
11 | * provided {@link ElementAccessor} as interpreted by the referenced
12 | * {@link ElementSerializer}. Any mutations to the returned signal are
13 | * automatically reflected back into the {@link ElementAccessor}.
14 | *
15 | * Automatically disables and re-enables itself based on the lifecycle of the
16 | * provided {@link ReactiveRoot}.
17 | *
18 | * @param el The {@link ElementAccessor} to initialize from and bind to.
19 | * @param root The {@link ReactiveRoot} to create the effect on. This
20 | * {@link live} call will disable / re-enable itself based on lifecycle of
21 | * this provided {@link ReactiveRoot}.
22 | * @param token A "token" which identifiers an {@link ElementSerializer} to
23 | * serialize the `signal` data to/from an element. A token is one of:
24 | * * A primitive serializer - {@link String}, {@link Boolean},
25 | * {@link Number}, {@link BigInt}.
26 | * * An {@link ElementSerializer} object.
27 | * * An {@link ElementSerializable} object.
28 | * @returns A {@link WriteableSignal} initialized to the deserialized form
29 | * of the provided element. Mutations to the signal value are automatically
30 | * serialized back into the same DOM.
31 | */
32 | export function live<
33 | El extends Element,
34 | Token extends ElementSerializerToken,
35 | >(
36 | el: ElementAccessor,
37 | root: ReactiveRoot,
38 | token: Token,
39 | ): WriteableSignal,
42 | ElementSerializable
43 | >>> {
44 | const serializer = resolveSerializer(token);
45 | const initial = el.read(serializer);
46 | const value = signal(initial);
47 |
48 | bind(el, root, serializer as any, value);
49 |
50 | return value as any;
51 | }
52 |
53 | /**
54 | * Invokes the given signal in a reactive context, serializes the result, and
55 | * renders it to the underlying element of this {@link ElementAccessor}.
56 | * Automatically re-renders whenever a signal dependency is modified.
57 | *
58 | * Automatically disables and re-enables itself based on the lifecycle of the
59 | * provided {@link ReactiveRoot}.
60 | *
61 | * @param el The {@link ElementAccessor} to bind to.
62 | * @param root The {@link ReactiveRoot} to create the effect on. This
63 | * {@link bind} call will disable / re-enable itself based on lifecycle of
64 | * this {@link ReactiveRoot}.
65 | * @param token A "token" which identifiers an {@link ElementSerializer} to
66 | * serialize the `signal` result to an element. A token is one of:
67 | * * A primitive serializer - {@link String}, {@link Boolean},
68 | * {@link Number}, {@link BigInt}.
69 | * * An {@link ElementSerializer} object.
70 | * * An {@link ElementSerializable} object.
71 | * @param sig The signal to invoke in a reactive context.
72 | */
73 | export function bind<
74 | Value,
75 | El extends Element,
76 | Token extends ElementSerializerToken,
77 | >(
78 | el: ElementAccessor,
79 | root: ReactiveRoot,
80 | token: Token,
81 | sig: Signal,
82 | ): void {
83 | // Assert that the element is not already bound to another signal.
84 | if (boundElements.has(el.element)) {
85 | throw new Error(`Element is already bound to another signal, cannot bind it again.`);
86 | }
87 | boundElements.add(el.element);
88 |
89 | // Resolve the serializer immediately, since that isn't dependent on the
90 | // value and we don't want to do this for every invocation of effect.
91 | const serializer = resolveSerializer(token) as ElementSerializer;
92 | root.effect(() => {
93 | // Invoke the user-defined callback in a reactive context.
94 | const value = sig();
95 |
96 | // Update the DOM with the new value.
97 | serializer.serializeTo(value, el.element);
98 | });
99 | }
100 |
--------------------------------------------------------------------------------
/src/signal-component-accessor.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `signal-component-accessor` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signal-component-accessor.test.ts:
--------------------------------------------------------------------------------
1 | import './testing/noop-component.js';
2 |
3 | import { SignalComponentAccessor } from './signal-component-accessor.js';
4 | import { ReactiveRootImpl } from './signals/reactive-root.js';
5 | import { TestScheduler } from './signals/schedulers/test-scheduler.js';
6 | import { StabilityTracker } from './signals/schedulers/stability-tracker.js';
7 |
8 | describe('signal-component-accessor', () => {
9 | describe('SignalComponentAccessor', () => {
10 | afterAll(() => {
11 | for (const el of document.body.children) el.remove();
12 | });
13 |
14 | describe('fromSignalComponent', () => {
15 | it('provides a `SignalComponentAccessor`', () => {
16 | const el = document.createElement('noop-component');
17 | const root = ReactiveRootImpl.from(
18 | el._connectable, StabilityTracker.from(), TestScheduler.from());
19 |
20 | expect(SignalComponentAccessor.fromSignalComponent(el, root))
21 | .toBeInstanceOf(SignalComponentAccessor);
22 | });
23 | });
24 |
25 | describe('effect', () => {
26 | it('schedules an effect on the provided `ReactiveRoot`', () => {
27 | const el = document.createElement('noop-component');
28 | document.body.append(el);
29 |
30 | const tracker = StabilityTracker.from();
31 | const scheduler = TestScheduler.from();
32 | const root = ReactiveRootImpl.from(
33 | el._connectable, tracker, scheduler);
34 |
35 | const effect = jasmine.createSpy<() => void>('effect');
36 |
37 | root.effect(effect);
38 | expect(tracker.isStable()).toBeFalse();
39 | expect(effect).not.toHaveBeenCalled();
40 |
41 | scheduler.flush();
42 | expect(effect).toHaveBeenCalledOnceWith();
43 | });
44 |
45 | it('schedules an effect with the provided scheduler', () => {
46 | const el = document.createElement('noop-component');
47 | document.body.append(el);
48 |
49 | const tracker = StabilityTracker.from();
50 | const defaultScheduler = TestScheduler.from();
51 | const root = ReactiveRootImpl.from(
52 | el._connectable, tracker, defaultScheduler);
53 |
54 | const effect = jasmine.createSpy<() => void>('effect');
55 | const customScheduler = TestScheduler.from();
56 |
57 | root.effect(effect, customScheduler);
58 | expect(effect).not.toHaveBeenCalled();
59 |
60 | defaultScheduler.flush(); // Default scheduler does nothing.
61 | expect(effect).not.toHaveBeenCalled();
62 |
63 | customScheduler.flush(); // Custom scheduler triggers effect.
64 | expect(effect).toHaveBeenCalled();
65 | });
66 | });
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/signal-component-accessor.ts:
--------------------------------------------------------------------------------
1 | import { ComponentAccessor } from './component-accessor.js';
2 | import { Connectable } from './connectable.js';
3 | import { HydroActiveComponent } from './hydroactive-component.js';
4 | import { QueryRoot } from './query-root.js';
5 | import { ReactiveRoot, Scheduler } from './signals.js';
6 |
7 | /**
8 | * Wraps a {@link HydroActiveComponent} in a convenient wrapper for querying and
9 | * accessing it's contents with serializers as well as working with signals.
10 | */
11 | export class SignalComponentAccessor
12 | extends ComponentAccessor implements ReactiveRoot {
13 | readonly #root: ReactiveRoot;
14 |
15 | private constructor(
16 | comp: Comp,
17 | queryRoot: QueryRoot,
18 | connectable: Connectable,
19 | root: ReactiveRoot,
20 | ) {
21 | super(comp, queryRoot, connectable);
22 |
23 | this.#root = root;
24 | }
25 |
26 | /**
27 | * Provides a {@link SignalComponentAccessor} for the given component.
28 | *
29 | * @param comp The {@link Comp} to wrap in an accessor.
30 | * @param root The {@link ReactiveRoot} to use for scheduling effects.
31 | * @returns A {@link SignalComponentAccessor} wrapping the given component.
32 | */
33 | public static fromSignalComponent(
34 | comp: Comp, root: ReactiveRoot): SignalComponentAccessor {
35 | return new SignalComponentAccessor(
36 | ...ComponentAccessor.fromComponentCtorArgs(comp),
37 | root,
38 | );
39 | }
40 |
41 | public effect(callback: () => void, scheduler?: Scheduler): void {
42 | this.#root.effect(callback, scheduler);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/signal-component.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `signal-component` tests
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/signal-component.test.ts:
--------------------------------------------------------------------------------
1 | import { type SignalHydrateLifecycle, signalComponent } from './signal-component.js';
2 | import { HydroActiveComponent } from './hydroactive-component.js';
3 | import { SignalComponentAccessor } from './signal-component-accessor.js';
4 | import { ReactiveRootImpl } from './signals/reactive-root.js';
5 | import { TestScheduler } from './signals/schedulers/test-scheduler.js';
6 | import { testCase, useTestCases } from './testing/test-cases.js';
7 | import { parseHtml } from './testing.js';
8 | import { StabilityTracker } from './signals/schedulers/stability-tracker.js';
9 |
10 | describe('signal-component', () => {
11 | useTestCases();
12 |
13 | afterEach(() => {
14 | for (const child of Array.from(document.body.childNodes)) {
15 | child.remove();
16 | }
17 | });
18 |
19 | describe('signalComponent', () => {
20 | it('upgrades already rendered components when defined', testCase('already-rendered', () => {
21 | const hydrate = jasmine.createSpy>('hydrate');
22 | const Comp = signalComponent('already-rendered', hydrate);
23 | Comp.define();
24 |
25 | expect(hydrate).toHaveBeenCalledTimes(1);
26 | }));
27 |
28 | it('upgrades components rendered after definition', () => {
29 | const hydrate = jasmine.createSpy>('hydrate');
30 |
31 | const Comp = signalComponent('new-component', hydrate);
32 | Comp.define();
33 | expect(hydrate).not.toHaveBeenCalled();
34 |
35 | const comp = document.createElement('new-component');
36 | expect(hydrate).not.toHaveBeenCalled();
37 |
38 | document.body.appendChild(comp);
39 | expect(hydrate).toHaveBeenCalledTimes(1);
40 | });
41 |
42 | it('invokes hydrate callback without a `this` value', () => {
43 | // Can't use Jasmine spies here because they will default `this` to `window`
44 | // because they are run in "sloppy mode".
45 | let self: unknown = 'defined' /* initial value other than undefined */;
46 | function hydrate(this: unknown): void {
47 | self = this;
48 | }
49 |
50 | const Comp = signalComponent('this-component', hydrate);
51 | Comp.define();
52 |
53 | const comp = document.createElement('this-component');
54 | document.body.appendChild(comp);
55 |
56 | expect(self).toBeUndefined();
57 | });
58 |
59 | it('invokes hydrate callback with a `SignalComponentAccessor` of the component host', () => {
60 | const hydrate = jasmine.createSpy>('hydrate');
61 | const Comp = signalComponent('host-component', hydrate);
62 | Comp.define();
63 |
64 | const comp =
65 | document.createElement('host-component') as HydroActiveComponent;
66 | document.body.appendChild(comp);
67 |
68 | const root = ReactiveRootImpl.from(
69 | comp._connectable, StabilityTracker.from(), TestScheduler.from());
70 | const accessor = SignalComponentAccessor.fromSignalComponent(comp, root);
71 | expect(hydrate).toHaveBeenCalledOnceWith(accessor);
72 | });
73 |
74 | it('applies the component definition returned by the `hydrate` callback', () => {
75 | const hydrate = jasmine.createSpy>('hydrate')
76 | .and.returnValue({ foo: 'bar' });
77 |
78 | const CompWithDef = signalComponent(
79 | 'signal-comp-with-def', hydrate);
80 |
81 | const el = parseHtml(CompWithDef, `
82 |
83 | `);
84 | document.body.appendChild(el);
85 |
86 | expect(el.foo).toBe('bar');
87 | });
88 |
89 | it('sets the class name', () => {
90 | const Comp = signalComponent('foo-bar-baz', () => {});
91 | expect(Comp.name).toBe('FooBarBaz');
92 | });
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/src/signal-component.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines symbols related to signal component definition. */
2 |
3 | import { applyDefinition, ComponentDefinition, HydroActiveComponent } from './hydroactive-component.js';
4 | import { SignalComponentAccessor } from './signal-component-accessor.js';
5 | import { ReactiveRootImpl } from './signals/reactive-root.js';
6 | import { skewerCaseToPascalCase } from './utils/casing.js';
7 | import { createDefine, Defineable } from './utils/on-demand-definitions.js';
8 | import { Class } from './utils/types.js';
9 | import type { baseComponent } from './base-component.js'; // For JSDoc link.
10 |
11 | /** The type of the lifecycle hook invoked when the component hydrates. */
12 | export type SignalHydrateLifecycle =
13 | (host: SignalComponentAccessor) => CompDef | void;
14 |
15 | /**
16 | * Declares a signal component of the given tag name with the provided hydration
17 | * callback.
18 | *
19 | * Signal components depend on signal APIs. This makes them more powerful than
20 | * their {@link baseComponent} counterparts at the cost of a slightly larger
21 | * overall bundle size.
22 | *
23 | * This does *not* define the element (doesn't call `customElements.define`) to
24 | * preserve tree-shakability of the component. Call the static `.define` method
25 | * on the returned class to define the custom element if necessary.
26 | *
27 | * ```typescript
28 | * const MyElement = defineSignalComponent('my-element', () => {});
29 | * MyElement.define();
30 | * ```
31 | *
32 | * @param tagName The tag name to use for the custom element.
33 | * @param hydrate The function to trigger when the component hydrates.
34 | * @returns The custom element class.
35 | */
36 | export function signalComponent(
37 | tagName: string,
38 | hydrate: SignalHydrateLifecycle,
39 | ): Class & Defineable {
40 | const Component = class extends HydroActiveComponent {
41 | // Implement the on-demand definitions community protocol.
42 | static define = createDefine(tagName, this);
43 |
44 | public override hydrate(): void {
45 | // Create an accessor for this element.
46 | const root = ReactiveRootImpl.from(
47 | this._connectable,
48 | this._tracker,
49 | this._defaultScheduler,
50 | );
51 | const accessor = SignalComponentAccessor.fromSignalComponent(this, root);
52 |
53 | // Hydrate this element.
54 | const compDef = hydrate(accessor);
55 |
56 | // Apply the component definition to this element.
57 | if (compDef) applyDefinition(this, compDef);
58 | }
59 | };
60 |
61 | // Set `name` for improved debug-ability.
62 | Object.defineProperty(Component, 'name', {
63 | value: skewerCaseToPascalCase(tagName),
64 | });
65 |
66 | return Component as unknown as
67 | Class & Defineable;
68 | }
69 |
--------------------------------------------------------------------------------
/src/signals.ts:
--------------------------------------------------------------------------------
1 | export * from './signals/index.js';
2 |
--------------------------------------------------------------------------------
/src/signals/cached.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `effect` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/cached.test.ts:
--------------------------------------------------------------------------------
1 | import { cached } from './cached.js';
2 | import { Consumer, observe } from './graph.js';
3 | import { signal } from './signal.js';
4 |
5 | describe('cached', () => {
6 | describe('cached', () => {
7 | it('invokes the callback and returns the value', () => {
8 | const computed = cached(() => 1);
9 |
10 | expect(computed()).toBe(1);
11 | });
12 |
13 | it('caches the value between multiple invocations', () => {
14 | const spy = jasmine.createSpy<() => number>('callback')
15 | .and.callFake(() => 1);
16 |
17 | const computed = cached(spy);
18 | expect(computed()).toBe(1);
19 | expect(spy).toHaveBeenCalledOnceWith();
20 | spy.calls.reset();
21 |
22 | expect(computed()).toBe(1);
23 | expect(spy).not.toHaveBeenCalled();
24 | });
25 |
26 | it('evaluates lazily', () => {
27 | const spy = jasmine.createSpy<() => undefined>('callback');
28 |
29 | cached(spy);
30 |
31 | expect(spy).not.toHaveBeenCalled();
32 | });
33 |
34 | it('links the current consumer on read', () => {
35 | const consumer = Consumer.from();
36 | spyOn(consumer, 'addProducer');
37 |
38 | const computed = cached(() => 1);
39 | observe(consumer, () => { computed(); });
40 |
41 | expect(consumer.addProducer).toHaveBeenCalled();
42 | });
43 |
44 | it('notifies consumers when a dependency changes', () => {
45 | const consumer = Consumer.from();
46 | spyOn(consumer, 'notifyListeners');
47 |
48 | const sig = signal(1);
49 | const computed = cached(() => sig());
50 | observe(consumer, () => { computed(); });
51 | expect(consumer.notifyListeners).not.toHaveBeenCalled();
52 |
53 | sig.set(2);
54 | expect(consumer.notifyListeners).toHaveBeenCalled();
55 | });
56 |
57 | it('rerecords new dependency when a dependency signal changes', () => {
58 | const value1 = signal(1);
59 | const value2 = signal(2);
60 |
61 | const consumer = Consumer.from();
62 | const notifyListeners = spyOn(consumer, 'notifyListeners');
63 |
64 | let calls = 0;
65 | const computed = cached((): number => {
66 | calls++;
67 | switch (calls) {
68 | case 1: {
69 | return value1();
70 | }
71 | case 2:
72 | case 3: {
73 | return value2();
74 | }
75 | default: {
76 | throw new Error('Unexpected call.');
77 | }
78 | }
79 | });
80 |
81 | expect(observe(consumer, () => computed())).toBe(1);
82 | expect(notifyListeners).not.toHaveBeenCalled();
83 |
84 | // Unrelated signals don't notify the consumer.
85 | value2.set(3);
86 | expect(notifyListeners).not.toHaveBeenCalled();
87 |
88 | // Dependency signal *does* notify the consumer.
89 | value1.set(4);
90 | expect(notifyListeners).toHaveBeenCalledOnceWith();
91 | notifyListeners.calls.reset();
92 |
93 | computed(); // Invoke to re-record dependencies.
94 |
95 | // Old dependencies don't notify the consumer.
96 | value1.set(5);
97 | expect(notifyListeners).not.toHaveBeenCalled();
98 |
99 | // New dependencies do notify the consumer.
100 | value2.set(6);
101 | expect(notifyListeners).toHaveBeenCalledOnceWith();
102 | });
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/src/signals/cached.ts:
--------------------------------------------------------------------------------
1 | import { Consumer, Producer, bindProducer } from './graph.js';
2 | import { Signal } from './types.js';
3 |
4 | /**
5 | * Creates a computed {@link Signal} whose result is cached.
6 | *
7 | * @param callback The callback to invoke to compute the value of this signal
8 | * based on other signals. The result of this callback is cached.
9 | * @returns The result of the callback, or the cached value of a previous
10 | * execution of the callback if nothing has changed.
11 | */
12 | export function cached(callback: () => Value): Signal {
13 | let value: Value;
14 | let dirty = true;
15 |
16 | // The consumer which is notified when any dependency signals change.
17 | const consumer = Consumer.from();
18 |
19 | // The producer which produces the result of the `cached` signal.
20 | const producer = Producer.from(() => {
21 | // Only invoke the callback if dirty, otherwise reuse the cached value.
22 | if (dirty) {
23 | value = consumer.record(callback);
24 | dirty = false;
25 | }
26 |
27 | return value;
28 | });
29 |
30 | // When a dependency changes, assume the `cached` is dirty and notify
31 | // downstream consumers. We actually might not have changed as a result of the
32 | // modified dependency, however we won't know that until the first consumer
33 | // re-invokes this `cached` signal.
34 | consumer.listen(() => {
35 | dirty = true;
36 | producer.notifyConsumers();
37 | });
38 |
39 | return () => {
40 | // On read, check if any consumer is watching the execution. If so, link it
41 | // to this signal's provider.
42 | bindProducer(producer);
43 |
44 | return producer.poll();
45 | };
46 | }
47 |
--------------------------------------------------------------------------------
/src/signals/effect.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `effect` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/effect.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Defines an effect, which applies a side-effectful operation
3 | * whenever a dependency signal changes.
4 | */
5 |
6 | import { Consumer } from './graph.js';
7 | import { MacrotaskScheduler } from './schedulers/macrotask-scheduler.js';
8 | import { CancelAction, Scheduler } from './schedulers/scheduler.js';
9 |
10 | const defaultScheduler = MacrotaskScheduler.from();
11 |
12 | /**
13 | * Creates an effect, which schedules a side-effectful callback whenever a
14 | * signal dependency of the callback is invoked.
15 | *
16 | * @param callback The callback function to schedule which applies the desired
17 | * side effect.
18 | * @param scheduler The {@link Scheduler} to use when scheduling `callback`
19 | * execution.
20 | * @returns An {@link EffectDisposer} which will dispose the effect and cancel
21 | * any scheduled operations.
22 | */
23 | export function effect(
24 | callback: () => void,
25 | scheduler: Scheduler = defaultScheduler,
26 | ): EffectDisposer {
27 | const consumer = Consumer.from();
28 | const cancelInitialCall = scheduler.schedule(() => {
29 | consumer.record(callback);
30 | });
31 |
32 | let cancelNextCall: CancelAction | undefined;
33 | let scheduled = false;
34 | consumer.listen(() => {
35 | // If already scheduled, nothing to do.
36 | // It might look like we could drop `scheduled` and use the presence of
37 | // `cancelNextCall` to know whether an event is scheduled, but this would
38 | // not work for a synchronous `Scheduler`.
39 | if (scheduled) return;
40 |
41 | scheduled = true;
42 | cancelNextCall = scheduler.schedule(() => {
43 | scheduled = false;
44 | consumer.record(callback);
45 | });
46 | });
47 |
48 | return () => {
49 | cancelInitialCall();
50 | if (scheduled) cancelNextCall!();
51 | consumer.destroy();
52 | };
53 | }
54 |
55 | /** Stops and disposes the associated effect. */
56 | export type EffectDisposer = () => void;
57 |
--------------------------------------------------------------------------------
/src/signals/graph.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `graph` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/index.ts:
--------------------------------------------------------------------------------
1 | export { cached } from './cached.js';
2 | export { effect } from './effect.js';
3 | export { untracked } from './graph.js';
4 | export { type ReactiveRoot } from './reactive-root.js';
5 | export { type Equals, signal } from './signal.js';
6 | export { MacrotaskScheduler } from './schedulers/macrotask-scheduler.js';
7 | export { type Action, type CancelAction, type Scheduler } from './schedulers/scheduler.js';
8 | export { type Signal, type WriteableSignal } from './types.js';
9 |
--------------------------------------------------------------------------------
/src/signals/reactive-root.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `reactive-root` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/reactive-root.ts:
--------------------------------------------------------------------------------
1 | import { Connectable } from '../connectable.js';
2 | import { effect } from './effect.js';
3 | import { StabilityTracker } from './schedulers/stability-tracker.js';
4 | import { Scheduler } from './schedulers/scheduler.js';
5 |
6 | /**
7 | * Represents the "root" of reactive effects. This manages starting and
8 | * stopping effects based on a component being attached to / detached from the
9 | * document.
10 | */
11 | export interface ReactiveRoot {
12 | /**
13 | * Create an effect which executes the given callback. The effect is
14 | * automatically enabled / disabled when the associated component attaches to
15 | * / disconnects from the document.
16 | *
17 | * @param callback The callback to invoke which executes a signal-based side
18 | * effect.
19 | * @param scheduler A scheduler to use for invoking the effect callback. If no
20 | * scheduler is provided, a default is used.
21 | */
22 | effect(callback: () => void, scheduler?: Scheduler): void;
23 | }
24 |
25 | /**
26 | * Represents the "root" of reactive effects. This manages starting and
27 | * stopping effects based on a component being attached to / detached from the
28 | * document.
29 | *
30 | * We need this class to be independent of the interface because otherwise ES
31 | * private variables leak into the type.
32 | */
33 | export class ReactiveRootImpl implements ReactiveRoot {
34 | readonly #connectable: Connectable;
35 | readonly #tracker: StabilityTracker;
36 | readonly #defaultScheduler: Scheduler;
37 |
38 | private constructor(
39 | connectable: Connectable,
40 | tracker: StabilityTracker,
41 | defaultScheduler: Scheduler,
42 | ) {
43 | this.#connectable = connectable;
44 | this.#tracker = tracker;
45 | this.#defaultScheduler = defaultScheduler;
46 | }
47 |
48 | /**
49 | * Provides a new {@link ReactiveRootImpl}.
50 | *
51 | * @param connectable The {@link Connectable} which tracks connectivity of the
52 | * component these effects will be scheduled with.
53 | * @param tracker A {@link StabilityTracker} to track stability of the root.
54 | * @param scheduler A default {@link Scheduler} to use when scheduling
55 | * effects.
56 | * @returns A {@link ReactiveRootImpl}.
57 | */
58 | public static from(
59 | connectable: Connectable,
60 | tracker: StabilityTracker,
61 | scheduler: Scheduler,
62 | ): ReactiveRootImpl {
63 | return new ReactiveRootImpl(connectable, tracker, scheduler);
64 | }
65 |
66 | public effect(
67 | callback: () => void,
68 | scheduler: Scheduler = this.#defaultScheduler,
69 | ): void {
70 | this.#connectable.connected(() => {
71 | const wrappedScheduler = this.#tracker.wrap(scheduler);
72 | return effect(callback, wrappedScheduler);
73 | });
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/signals/schedulers/macrotask-scheduler.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `macrotask-scheduler` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/schedulers/macrotask-scheduler.test.ts:
--------------------------------------------------------------------------------
1 | import { MacrotaskScheduler } from './macrotask-scheduler.js';
2 |
3 | describe('macrotask-scheduler', () => {
4 | beforeAll(() => {
5 | jasmine.clock().install();
6 | });
7 |
8 | afterAll(() => {
9 | jasmine.clock().uninstall();
10 | });
11 |
12 | describe('MacrotaskScheduler', () => {
13 | describe('scheduler', () => {
14 | it('schedules via a macrotask', () => {
15 | const scheduler = MacrotaskScheduler.from();
16 | const action = jasmine.createSpy<() => void>('action');
17 |
18 | scheduler.schedule(action);
19 | expect(action).not.toHaveBeenCalled();
20 |
21 | jasmine.clock().tick(0);
22 |
23 | expect(action).toHaveBeenCalledOnceWith();
24 | });
25 |
26 | it('cancels a scheduled action when the cancel callback is invoked', () => {
27 | const scheduler = MacrotaskScheduler.from();
28 | const action = jasmine.createSpy<() => void>('action');
29 |
30 | const cancel = scheduler.schedule(action);
31 | expect(action).not.toHaveBeenCalled();
32 |
33 | cancel();
34 | expect(action).not.toHaveBeenCalled();
35 |
36 | jasmine.clock().tick(0);
37 |
38 | expect(action).not.toHaveBeenCalled();
39 | });
40 |
41 | it('schedules multiple actions concurrently', () => {
42 | const scheduler = MacrotaskScheduler.from();
43 | const action1 = jasmine.createSpy<() => void>('action1');
44 | const action2 = jasmine.createSpy<() => void>('action2');
45 |
46 | scheduler.schedule(action1);
47 | scheduler.schedule(action2);
48 |
49 | expect(action1).not.toHaveBeenCalled();
50 | expect(action2).not.toHaveBeenCalled();
51 |
52 | jasmine.clock().tick(0);
53 |
54 | expect(action1).toHaveBeenCalled();
55 | expect(action2).toHaveBeenCalled();
56 | });
57 |
58 | it('schedules multiple actions *without* batching them together', () => {
59 | const scheduler = MacrotaskScheduler.from();
60 |
61 | const calls: Array = [];
62 | const action1 = jasmine.createSpy<() => void>('action1')
63 | .and.callFake(() => { calls.push(action1); });
64 | const action2 = jasmine.createSpy<() => void>('action2')
65 | .and.callFake(() => { calls.push(action2); });
66 | const action3 = jasmine.createSpy<() => void>('action3')
67 | .and.callFake(() => { calls.push(action3); });
68 |
69 | scheduler.schedule(action1);
70 | setTimeout(action2);
71 | scheduler.schedule(action3);
72 |
73 | expect(calls).toEqual([]);
74 |
75 | jasmine.clock().tick(0);
76 |
77 | expect(calls).toEqual([ action1, action2, action3 ]);
78 | });
79 |
80 | it('cancels actions independently', () => {
81 | const scheduler = MacrotaskScheduler.from();
82 | const action1 = jasmine.createSpy<() => void>('action1');
83 | const action2 = jasmine.createSpy<() => void>('action2');
84 |
85 | const cancel1 = scheduler.schedule(action1);
86 | scheduler.schedule(action2);
87 |
88 | cancel1();
89 |
90 | expect(action1).not.toHaveBeenCalled();
91 | expect(action2).not.toHaveBeenCalled();
92 |
93 | jasmine.clock().tick(0);
94 |
95 | expect(action1).not.toHaveBeenCalled();
96 | expect(action2).toHaveBeenCalledOnceWith();
97 | });
98 |
99 | it('ignores canceling actions which have already executed', async () => {
100 | const scheduler = MacrotaskScheduler.from();
101 | const action1 = jasmine.createSpy<() => void>('action1');
102 | const action2 = jasmine.createSpy<() => void>('action2');
103 |
104 | const cancel = scheduler.schedule(action1);
105 |
106 | jasmine.clock().tick(0);
107 | expect(action1).toHaveBeenCalledOnceWith();
108 | action1.calls.reset();
109 |
110 | expect(() => cancel()).not.toThrow();
111 | expect(action1).not.toHaveBeenCalled();
112 |
113 | scheduler.schedule(action2);
114 |
115 | jasmine.clock().tick(0);
116 | expect(action1).not.toHaveBeenCalledOnceWith();
117 | expect(action2).toHaveBeenCalledOnceWith();
118 | });
119 | });
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/src/signals/schedulers/macrotask-scheduler.ts:
--------------------------------------------------------------------------------
1 | import { Action, CancelAction, Scheduler } from './scheduler.js';
2 |
3 | let singletonScheduler: MacrotaskScheduler | undefined;
4 |
5 | /**
6 | * A {@link Scheduler} implementation which schedules actions to be run on the
7 | * next macrotask. Does *not* batch multiple actions together into a single
8 | * macrotask.
9 | */
10 | export class MacrotaskScheduler implements Scheduler {
11 | /** Provides a {@link MacrotaskScheduler}. */
12 | public static from(): MacrotaskScheduler {
13 | if (!singletonScheduler) singletonScheduler = new MacrotaskScheduler();
14 | return singletonScheduler;
15 | }
16 |
17 | public schedule(callback: Action): CancelAction {
18 | const handle = setTimeout(() => { callback(); });
19 |
20 | return () => { clearInterval(handle); };
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/signals/schedulers/scheduler.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `scheduler` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/schedulers/scheduler.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines an interface for scheduling work. */
2 |
3 | /**
4 | * Schedules a callback to be invoked at a later time. The precise timing of
5 | * invocation is intentionally left as an implementation detail. Each scheduler
6 | * implementation may define its own algorithm for determining when an action
7 | * should be executed.
8 | */
9 | export interface Scheduler {
10 | /**
11 | * Schedules the given action to be invoked. Returns a {@link CancelAction}
12 | * function which cancels the scheduled action.
13 | *
14 | * Multiple calls to the returned {@link CancelAction} function have no
15 | * effect. Calling the returned {@link CancelAction} function after
16 | * {@link action} was executed has no effect.
17 | *
18 | * @param action The {@link Action} to schedule.
19 | * @returns A callback which, when invoked, cancels {@link action} from ever
20 | * being executed.
21 | */
22 | schedule(action: Action): CancelAction;
23 | }
24 |
25 | /** A side-effectful action to be scheduled and invoked. */
26 | export type Action = () => void;
27 |
28 | /** A function which cancels an already scheduled action. */
29 | export type CancelAction = () => void;
30 |
--------------------------------------------------------------------------------
/src/signals/schedulers/stability-tracker.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `stability-tracker` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/schedulers/sync-scheduler.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `sync-scheduler` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/schedulers/sync-scheduler.test.ts:
--------------------------------------------------------------------------------
1 | import { Action } from './scheduler.js';
2 | import { syncScheduler } from './sync-scheduler.js';
3 |
4 | describe('sync-scheduler', () => {
5 | describe('SyncScheduler', () => {
6 | describe('schedule', () => {
7 | it('executes the given action synchronously', () => {
8 | const action = jasmine.createSpy('action');
9 |
10 | syncScheduler.schedule(action);
11 | expect(action).toHaveBeenCalledOnceWith();
12 | });
13 |
14 | it('ignores cancel operations', () => {
15 | const action = jasmine.createSpy('action');
16 |
17 | const cancel = syncScheduler.schedule(action);
18 | expect(cancel).not.toThrow();
19 | });
20 | });
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/signals/schedulers/sync-scheduler.ts:
--------------------------------------------------------------------------------
1 | import { Action, CancelAction, Scheduler } from './scheduler.js';
2 |
3 | /**
4 | * A {@link Scheduler} implementation which always executes operations
5 | * synchronously, immediately when scheduled.
6 | */
7 | class SyncScheduler implements Scheduler {
8 | public schedule(action: Action): CancelAction {
9 | action();
10 | return () => {};
11 | }
12 | }
13 |
14 | /** Singleton {@link SyncScheduler} for shared usage. */
15 | export const syncScheduler = new SyncScheduler();
16 |
--------------------------------------------------------------------------------
/src/signals/schedulers/test-scheduler.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `test-scheduler` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/schedulers/test-scheduler.test.ts:
--------------------------------------------------------------------------------
1 | import { TestScheduler } from './test-scheduler.js';
2 |
3 | describe('test-scheduler', () => {
4 | describe('TestScheduler', () => {
5 | it('schedules actions and executes them when flushed', () => {
6 | const scheduler = TestScheduler.from();
7 | const action = jasmine.createSpy<() => void>('action');
8 |
9 | scheduler.schedule(action);
10 | expect(action).not.toHaveBeenCalled();
11 |
12 | scheduler.flush();
13 | expect(action).toHaveBeenCalledOnceWith();
14 | });
15 |
16 | it('throws an aggregate of all errors thrown from a flush', () => {
17 | const scheduler = TestScheduler.from();
18 | const err1 = new Error('Oh noes!');
19 | const action1 = () => { throw err1; };
20 | const action2 = jasmine.createSpy<() => void>('action');
21 | const err2 = new Error('Oh noes again!');
22 | const action3 = () => { throw err2; };
23 |
24 | scheduler.schedule(action1);
25 | scheduler.schedule(action2);
26 | scheduler.schedule(action3);
27 |
28 | expect(() => scheduler.flush()).toThrowMatching((err) => {
29 | return err instanceof AggregateError
30 | && err.errors.length === 2
31 | && err.errors.includes(err1)
32 | && err.errors.includes(err2);
33 | });
34 | expect(action2).toHaveBeenCalled();
35 | });
36 |
37 | it('cancels a scheduled action when the cancel callback is invoked', () => {
38 | const scheduler = TestScheduler.from();
39 |
40 | const action1 = jasmine.createSpy<() => void>('action');
41 | const action2 = jasmine.createSpy<() => void>('action');
42 | const action3 = jasmine.createSpy<() => void>('action');
43 |
44 | scheduler.schedule(action1);
45 | const cancelAction2 = scheduler.schedule(action2);
46 | scheduler.schedule(action3);
47 |
48 | cancelAction2();
49 |
50 | scheduler.flush();
51 |
52 | expect(action1).toHaveBeenCalled();
53 | expect(action2).not.toHaveBeenCalled();
54 | expect(action3).toHaveBeenCalled();
55 | });
56 |
57 | it('does nothing when an already executed action is canceled', () => {
58 | const scheduler = TestScheduler.from();
59 | const action = jasmine.createSpy<() => void>('action');
60 |
61 | const cancel = scheduler.schedule(action);
62 |
63 | scheduler.flush();
64 | expect(action).toHaveBeenCalled();
65 |
66 | expect(() => cancel()).not.toThrow();
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/signals/schedulers/test-scheduler.ts:
--------------------------------------------------------------------------------
1 | import { Action, CancelAction, Scheduler } from './scheduler.js';
2 |
3 | /**
4 | * A {@link Scheduler} implementation for use in testing. Does not ever
5 | * automatically invoke a scheduled task, but instead waits to be manually
6 | * flushed during a test.
7 | */
8 | export class TestScheduler implements Scheduler {
9 | /** Queue of actions to be invoked. */
10 | readonly #queue: Array = [];
11 |
12 | /** Constructs a new {@link TestScheduler}. */
13 | public static from(): TestScheduler {
14 | return new TestScheduler();
15 | }
16 |
17 | public schedule(action: Action): CancelAction {
18 | this.#queue.push(action);
19 |
20 | return () => {
21 | const index = this.#queue.findIndex((a) => a === action);
22 | if (index === -1) return; // Already executed.
23 |
24 | this.#queue.splice(index, 1);
25 | }
26 | }
27 |
28 | /** Executes all pending actions. */
29 | public flush(): void {
30 | const errors: unknown[] = [];
31 | for (const action of this.#queue) {
32 | try {
33 | action();
34 | } catch (err) {
35 | errors.push(err);
36 | }
37 | }
38 |
39 | this.#queue.splice(0, this.#queue.length);
40 |
41 | if (errors.length !== 0) {
42 | throw new AggregateError(errors, 'One or more scheduled actions threw.');
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/signals/schedulers/ui-scheduler.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `ui-scheduler` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/schedulers/ui-scheduler.test.ts:
--------------------------------------------------------------------------------
1 | import { nextFrame } from '../../testing/timing.js';
2 | import { UiScheduler } from './ui-scheduler.js';
3 |
4 | describe('ui-scheduler', () => {
5 | describe('UiScheduler', () => {
6 | describe('schedule', () => {
7 | it('schedules on the next animation frame', async () => {
8 | const scheduler = UiScheduler.from();
9 | const action = jasmine.createSpy<() => void>('action');
10 |
11 | scheduler.schedule(action);
12 | expect(action).not.toHaveBeenCalled();
13 |
14 | await nextFrame();
15 | expect(action).toHaveBeenCalledOnceWith();
16 | });
17 |
18 | it('cancels a scheduled action when the cancel callback is invoked', async () => {
19 | const scheduler = UiScheduler.from();
20 | const action = jasmine.createSpy<() => void>('action');
21 |
22 | const cancel = scheduler.schedule(action);
23 | expect(action).not.toHaveBeenCalled();
24 |
25 | cancel();
26 | expect(action).not.toHaveBeenCalled();
27 |
28 | await nextFrame();
29 | expect(action).not.toHaveBeenCalled();
30 | });
31 |
32 | it('schedules multiple actions concurrently', async () => {
33 | const scheduler = UiScheduler.from();
34 | const action1 = jasmine.createSpy<() => void>('action1');
35 | const action2 = jasmine.createSpy<() => void>('action2');
36 |
37 | scheduler.schedule(action1);
38 | scheduler.schedule(action2);
39 |
40 | expect(action1).not.toHaveBeenCalled();
41 | expect(action2).not.toHaveBeenCalled();
42 |
43 | await nextFrame();
44 |
45 | expect(action1).toHaveBeenCalled();
46 | expect(action2).toHaveBeenCalled();
47 | });
48 |
49 | it('schedules multiple actions *without* batching them together', async () => {
50 | const scheduler = UiScheduler.from();
51 |
52 | const calls: Array = [];
53 | const action1 = jasmine.createSpy<() => void>('action1')
54 | .and.callFake(() => { calls.push(action1); });
55 | const action2 = jasmine.createSpy<() => void>('action2')
56 | .and.callFake(() => { calls.push(action2); });
57 | const action3 = jasmine.createSpy<() => void>('action3')
58 | .and.callFake(() => { calls.push(action3); });
59 |
60 | scheduler.schedule(action1);
61 | requestAnimationFrame(action2);
62 | scheduler.schedule(action3);
63 |
64 | expect(calls).toEqual([]);
65 |
66 | await nextFrame();
67 |
68 | expect(calls).toEqual([ action1, action2, action3 ]);
69 | });
70 |
71 | it('cancels actions independently', async () => {
72 | const scheduler = UiScheduler.from();
73 | const action1 = jasmine.createSpy<() => void>('action1');
74 | const action2 = jasmine.createSpy<() => void>('action2');
75 |
76 | const cancel1 = scheduler.schedule(action1);
77 | scheduler.schedule(action2);
78 |
79 | cancel1();
80 |
81 | expect(action1).not.toHaveBeenCalled();
82 | expect(action2).not.toHaveBeenCalled();
83 |
84 | await nextFrame();
85 |
86 | expect(action1).not.toHaveBeenCalled();
87 | expect(action2).toHaveBeenCalledOnceWith();
88 | });
89 |
90 | it('ignores canceling actions which have already executed', async () => {
91 | const scheduler = UiScheduler.from();
92 | const action1 = jasmine.createSpy<() => void>('action1');
93 | const action2 = jasmine.createSpy<() => void>('action2');
94 |
95 | const cancel = scheduler.schedule(action1);
96 |
97 | await nextFrame();
98 | expect(action1).toHaveBeenCalledOnceWith();
99 | action1.calls.reset();
100 |
101 | expect(() => cancel()).not.toThrow();
102 | expect(action1).not.toHaveBeenCalled();
103 |
104 | scheduler.schedule(action2);
105 |
106 | await nextFrame();
107 | expect(action1).not.toHaveBeenCalledOnceWith();
108 | expect(action2).toHaveBeenCalledOnceWith();
109 | });
110 | });
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/src/signals/schedulers/ui-scheduler.ts:
--------------------------------------------------------------------------------
1 | import { Action, CancelAction, Scheduler } from './scheduler.js';
2 |
3 | /**
4 | * A {@link Scheduler} implementation which schedules actions to be run on the
5 | * next animation frame. Does *not* batch multiple actions together into a
6 | * single {@link requestAnimationFrame} call. This scheduler is ideal for
7 | * scheduling UI operations which affect the DOM.
8 | */
9 | export class UiScheduler implements Scheduler {
10 | /** Provides a {@link UiScheduler}. */
11 | public static from(): UiScheduler {
12 | return new UiScheduler();
13 | }
14 |
15 | public schedule(action: Action): CancelAction {
16 | const handle = requestAnimationFrame(() => { action(); });
17 |
18 | return () => { cancelAnimationFrame(handle); };
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/signals/signal.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `signal` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/signals/signal.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines the creation of the core signal primitive. */
2 |
3 | import { Producer, bindProducer } from './graph.js';
4 | import { WriteableSignal } from './types.js';
5 |
6 | /**
7 | * Creates a new {@link WriteableSignal} with the given initial value.
8 | *
9 | * @param initial The initial value to use for the signal.
10 | * @returns A {@link WriteableSignal} which can be read and mutated.
11 | */
12 | export function signal(initial: Value, { equals = Object.is }: {
13 | equals?: Equals,
14 | } = {}): WriteableSignal {
15 | let value = initial;
16 | const producer = Producer.from(() => value);
17 |
18 | const sig: WriteableSignal = () => {
19 | // On read, check if any consumer is watching the execution. If so, link it
20 | // to this signal's producer.
21 | bindProducer(producer);
22 |
23 | return producer.poll();
24 | };
25 | sig.set = (val: Value) => {
26 | // Check if the new values are equivalent, so we can skip rerunning
27 | // downstream computations.
28 | const dirty = !equals(val, value);
29 |
30 | // Update the current value. Even if the new value is equivalent to the old
31 | // one, it could still be different in observable ways, so we always want to
32 | // update this, even if it isn't actually "dirty".
33 | value = val;
34 |
35 | // Notify consumers only if the value has actually changed.
36 | if (dirty) producer.notifyConsumers();
37 | };
38 | sig.readonly = () => () => sig();
39 |
40 | return sig as WriteableSignal;
41 | }
42 |
43 | /**
44 | * An equality comparator. Returns whether or not the two values are considered
45 | * "equivalent".
46 | *
47 | * The precise semantics of what "equivalent" means is up to the implementation.
48 | * Generally speaking "equivalent" means that the two values represent the same
49 | * underlying content.
50 | *
51 | * @param left The first value to compare.
52 | * @param right The second value to compare.
53 | * @returns `true` if the two values are considered "equivalent", `false`
54 | * otherwise.
55 | */
56 | export type Equals = (left: Value, right: Value) => boolean;
57 |
--------------------------------------------------------------------------------
/src/signals/types.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines core signal types. */
2 |
3 | /**
4 | * A readonly signal which returns type `Value` and can be observed to trigger
5 | * events when the underlying value changes.
6 | */
7 | export type Signal = () => Value;
8 |
9 | /**
10 | * A read/write signal which holds type `Value` and can be observed to trigger
11 | * events when modified.
12 | */
13 | export interface WriteableSignal extends Signal {
14 | /**
15 | * Updates the current value and notifies any observers that the signal has
16 | * changed.
17 | *
18 | * @param value The value to set the signal to.
19 | */
20 | set(value: Value): void;
21 |
22 | /**
23 | * Provides a readonly accessor of this signal.
24 | *
25 | * @returns A readonly accessor of this signal.
26 | */
27 | readonly(): Signal;
28 | }
29 |
--------------------------------------------------------------------------------
/src/testing.ts:
--------------------------------------------------------------------------------
1 | export * from './testing/index.js';
2 |
--------------------------------------------------------------------------------
/src/testing/html-parser.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `html-parser` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/testing/html-parser.test.ts:
--------------------------------------------------------------------------------
1 | import { parseHtml } from './html-parser.js';
2 | import { NoopComponent } from './noop-component.js';
3 |
4 | describe('html-parser', () => {
5 | describe('parseHtml', () => {
6 | it('parses HTML', () => {
7 | const div = parseHtml(HTMLDivElement, `Hello, World!
`);
8 |
9 | expect(div).toBeInstanceOf(HTMLDivElement);
10 | expect(div.textContent).toBe('Hello, World!');
11 | });
12 |
13 | it('parses HTML with provided custom elements', () => {
14 | const div = parseHtml(HTMLDivElement, `
15 |
16 |
17 |
18 | `, [ NoopComponent ]);
19 |
20 | expect(div.querySelector('noop-component')).toBeInstanceOf(NoopComponent);
21 | });
22 |
23 | it('returns an instance of the result element provided', () => {
24 | const comp = parseHtml(NoopComponent, `
25 |
26 | `);
27 |
28 | expect(comp).toBeInstanceOf(NoopComponent);
29 | });
30 |
31 | it('return type is an instance of the result element provided', () => {
32 | // Type-only test, only needs to compile, not execute.
33 | expect().nothing();
34 | () => {
35 | let comp: NoopComponent = parseHtml(NoopComponent, `
36 |
37 | `);
38 | };
39 | });
40 |
41 | it('throws an error when given multiple root nodes', () => {
42 | expect(() => parseHtml(HTMLDivElement, ``))
43 | .toThrowError(/Expected parsed HTML to have exactly \*one\* root element/);
44 | });
45 |
46 | it('throws an error when missing component definitions', () => {
47 | expect(() => parseHtml(HTMLDivElement, `
48 |
49 |
50 |
51 |
52 | `)).toThrowError(/Did you forget to add the component classes.*comp-1.*comp-2/s);
53 | });
54 |
55 | it('throws an error of only the missing component definitions', () => {
56 | const err = extractError(() => parseHtml(HTMLDivElement, `
57 |
58 |
59 |
60 |
61 | `, [ NoopComponent ]));
62 |
63 | expect(err.message).toContain('missing-component');
64 | expect(err.message).not.toContain('noop-component');
65 | });
66 |
67 | it('throws an error when given the wrong top-level element', () => {
68 | expect(() => parseHtml(HTMLDivElement, `
69 |
70 | `, [ NoopComponent ]))
71 | .toThrowError(/Expected parsed top-level element to be an instance of.*HTMLDivElement.*NoopComponent/);
72 | });
73 |
74 | it('throws an error when given the wrong top-level *custom* element', () => {
75 | expect(() => parseHtml(NoopComponent, ``))
76 | .toThrowError(/Expected parsed top-level element to be an instance of.*NoopComponent.*HTMLDivElement/);
77 | });
78 |
79 | it('returns an HTML element owned by the current document', () => {
80 | const div = parseHtml(HTMLDivElement, `Hello, World!
`);
81 |
82 | expect(div.ownerDocument).toBe(document);
83 | });
84 |
85 | it('evaluates declarative shadow DOM', () => {
86 | const div = parseHtml(HTMLDivElement, `
87 |
88 |
89 |
90 |
91 |
92 | `);
93 |
94 | expect(div.children.length).toBe(0);
95 | expect(div.shadowRoot!.children.length).toBe(1);
96 | expect(div.shadowRoot!.children[0]!).toBeInstanceOf(HTMLSpanElement);
97 | });
98 | });
99 | });
100 |
101 | /** Catches an error thrown in the given callback and returns it. */
102 | function extractError(callback: () => void): Error {
103 | try {
104 | callback();
105 | } catch (err) {
106 | return err as Error;
107 | }
108 |
109 | throw new Error(`Expected callback to throw but it did not.`);
110 | }
111 |
--------------------------------------------------------------------------------
/src/testing/html-parser.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Defines utilities related to parsing HTML for testing. */
2 |
3 | import { defineIfSupported } from '../utils/on-demand-definitions.js';
4 |
5 | /**
6 | * Parses the given HTML text and returns the root element. The result is
7 | * asserted and typed as an instance of `resultComponent`. Any rendered custom
8 | * element classes *must* be included in either `resultComponent` or the
9 | * `dependencies` array. The `resultComponent` is implicitly considered a
10 | * dependency and does not need to be explicitly included in `dependencies`.
11 | *
12 | * @param resultComponent The component class of the top-level element in the
13 | * HTML text. This is also used as the return type.
14 | * @param html The HTML text to parse.
15 | * @param dependencies Additional custom element classes used in the HTML text.
16 | * @returns The root element of the parsed input HTML, typed as
17 | * an instance of `resultComponent`.
18 | *
19 | * @throws If multiple root elements are found. Only one root is allowed.
20 | * @throws If any rendered elements are custom elements and not included as the
21 | * `resultComponent` or in the `dependencies` array.
22 | * @throws If the parsed root element is not an instance of `resultComponent`.
23 | */
24 | export function parseHtml(
25 | resultComponent: Result,
26 | html: string,
27 | dependencies: typeof Element[] = [],
28 | ): InstanceType {
29 | const deps = dependencies.concat(resultComponent);
30 | for (const dep of deps) defineIfSupported(dep);
31 |
32 | const doc = Document.parseHTMLUnsafe(html);
33 |
34 | // Assert that exactly one root element is returned.
35 | const [ rootEl ] = doc.body.children;
36 | if (!rootEl || doc.body.children.length > 1) {
37 | throw new Error(
38 | `Expected parsed HTML to have exactly *one* root element:\n\n${html}`);
39 | }
40 |
41 | // `parseHTMLUnsafe` puts elements into a different document, so we need to
42 | // adopt them to the current document before they can be upgraded.
43 | const el = document.adoptNode(rootEl) as Element;
44 |
45 | // Parsed HTML might contain custom elements, upgrade it before returning.
46 | customElements.upgrade(el);
47 |
48 | // Assert all rendered custom elements have been defined and are dependencies.
49 | const undefinedTags = new Set(Array.from(walk(rootEl))
50 | .filter((el) => isCustomElement(el))
51 | .filter((el) => !deps.some((comp) => el instanceof comp))
52 | .map((el) => el.tagName.toLowerCase()));
53 | if (undefinedTags.size > 0) {
54 | throw new Error(`Parsed HTML without associated component definitions. Did you forget to add the component classes for the following tags as dependencies to \`parseHtml\`?:\n${
55 | Array.from(undefinedTags).join('\n')}`);
56 | }
57 |
58 | // Assert that the correct class is being returned in the top-level element.
59 | const expectedResultComp = resultComponent;
60 | const expectedComponentName = expectedResultComp.name;
61 | const actualComponentName = el.constructor.name;
62 | if (!(el instanceof expectedResultComp)) {
63 | throw new Error(`Expected parsed top-level element to be an instance of \`${
64 | expectedComponentName}\` but was actually an instance of \`${
65 | actualComponentName}\`.`);
66 | }
67 |
68 | return el as InstanceType;
69 | }
70 |
71 | // Walks the DOM hierarchy of all element descendants of the provided element.
72 | function* walk(el: Element): Generator {
73 | yield el;
74 | for (const child of el.children) {
75 | yield* walk(child);
76 | }
77 | }
78 |
79 | function isCustomElement(el: Element): boolean {
80 | // Custom elements are *required* to have a `-` in their name.
81 | return el.tagName.includes('-');
82 | }
83 |
--------------------------------------------------------------------------------
/src/testing/index.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Public API of HydroActive testing utilities. */
2 |
3 | export { parseHtml } from './html-parser.js';
4 |
--------------------------------------------------------------------------------
/src/testing/noop-component.ts:
--------------------------------------------------------------------------------
1 | import { ReactiveRoot } from '../signals.js';
2 | import { ReactiveRootImpl } from '../signals/reactive-root.js';
3 | import { HydroActiveComponent } from '../hydroactive-component.js';
4 | import { SignalComponentAccessor } from '../signal-component-accessor.js';
5 |
6 | /**
7 | * A component which does nothing on hydration. Useful for tests which need a
8 | * component but don't require it to actually do anything. This avoids each test
9 | * defining its own components and potentially conflicting tag names.
10 | */
11 | export class NoopComponent extends HydroActiveComponent {
12 | #accessor!: SignalComponentAccessor;
13 |
14 | public readonly root: ReactiveRoot;
15 |
16 | public hydrated?: true;
17 |
18 | public constructor() {
19 | super();
20 |
21 | this.root = ReactiveRootImpl.from(
22 | this._connectable,
23 | this._tracker,
24 | this._defaultScheduler,
25 | );
26 | this.#accessor =
27 | SignalComponentAccessor.fromSignalComponent(this, this.root);
28 | }
29 |
30 | protected override hydrate(): void {
31 | this.hydrated = true;
32 | }
33 |
34 | public getComponentAccessor(): SignalComponentAccessor {
35 | return this.#accessor;
36 | }
37 | }
38 |
39 | customElements.define('noop-component', NoopComponent);
40 |
41 | declare global {
42 | interface HTMLElementTagNameMap {
43 | 'noop-component': NoopComponent;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/testing/test-cases.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Index Tests
5 |
6 |
7 |
8 |
9 | First test
10 |
11 |
12 |
13 | Second test
14 |
15 |
16 |
17 |
18 |
19 | First
20 | Second
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/testing/test-cases.test.ts:
--------------------------------------------------------------------------------
1 | import { testCase, useTestCases } from './test-cases.js';
2 |
3 | describe('test-cases', () => {
4 | describe('testCase', () => {
5 | useTestCases();
6 |
7 | it('runs standard test case', testCase('first', (el) => {
8 | expect(el.tagName).toBe('H1');
9 | expect(el.textContent).toBe('First test');
10 | }));
11 |
12 | it('runs a second test case', testCase('second', (el) => {
13 | expect(el.tagName).toBe('H1');
14 | expect(el.textContent).toBe('Second test');
15 | }));
16 |
17 | it('runs a single test case a second time', testCase('first', (el) => {
18 | expect(el.tagName).toBe('H1');
19 | expect(el.textContent).toBe('First test');
20 | }));
21 |
22 | it('throws when given a non-existent test case', async () => {
23 | await expectAsync(testCase('does-not-exist', () => {})())
24 | .toBeRejectedWithError(/No test case named `does-not-exist`\./);
25 | });
26 |
27 | it('throws when given a test case with no root element', async () => {
28 | await expectAsync(testCase('no-root', () => {})())
29 | .toBeRejectedWithError(
30 | /Test case `no-root` must contain exactly \*one\* root element\./);
31 | });
32 |
33 | it('throws when given a test case with multiple root elements', async () => {
34 | await expectAsync(testCase('multiple-roots', () => {})())
35 | .toBeRejectedWithError(
36 | /Test case `multiple-roots` must contain exactly \*one\* root element\./);
37 | });
38 |
39 | it('throws when given a test case with a node instead of an element', async () => {
40 | await expectAsync(testCase('multiple-roots', () => {})())
41 | .toBeRejectedWithError(
42 | /Test case `multiple-roots` must contain exactly \*one\* root element\./);
43 | });
44 | });
45 |
46 | describe('testCase with empty document', () => {
47 | const rootChildren: Node[] = [];
48 | beforeAll(() => {
49 | for (const el of Array.from(document.body.children)) {
50 | rootChildren.push(el);
51 | el.remove();
52 | }
53 | });
54 |
55 | afterAll(() => {
56 | for (const el of rootChildren) {
57 | document.body.append(el);
58 | }
59 |
60 | rootChildren.splice(0, rootChildren.length);
61 | });
62 |
63 | useTestCases();
64 |
65 | it('throws an error that no test cases were added', async () => {
66 | await expectAsync(testCase('does-not-exist', () => {})())
67 | .toBeRejectedWithError(/No test cases were found on the page\./);
68 | });
69 | });
70 |
71 | describe('testCase without useTestCases', () => {
72 | it('throws an error that no tests cases were found', async () => {
73 | await expectAsync(testCase('does-not-exist', () => {})())
74 | .toBeRejectedWithError(/No test cases were found on the page\./);
75 | });
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/testing/test-cases.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Utilities for testing prerendered HTML.
3 | *
4 | * Usage:
5 | *
6 | * ```html
7 | *
8 | *
9 | *
10 | *
11 | * Hello, World!
12 | *
13 | *
14 | *
15 | * ```
16 | *
17 | * ```typescript
18 | * import { testCase, useTestCases } from './testing/test-cases.js';
19 | *
20 | * describe('tests', () => {
21 | * useTestCases();
22 | *
23 | * // Renders and provides `my-test-case` from the prerendered template.
24 | * it('does a thing', testCase('my-test-case', (el) => {
25 | * expect(el.textContent).toBe('Hello, World!');
26 | * }));
27 | * });
28 | * ```
29 | */
30 |
31 | /** Holds number of test case templates keyed by their name. */
32 | const testCaseMap = new Map();
33 |
34 | /**
35 | * A "hook" to be called in a `describe` block which identifies and removes all
36 | * test cases from the DOM, tracking them to be rendered and added by
37 | * {@link testCase} when needed.
38 | */
39 | export function useTestCases(): void {
40 | // Find all test cases before tests run and remove them from the DOM.
41 | beforeAll(() => {
42 | for (const rootEl of Array.from(document.body.children)) {
43 | if (!(rootEl instanceof HTMLTemplateElement)) continue;
44 |
45 | const name = rootEl.getAttribute('test-case');
46 | if (!name) continue;
47 |
48 | rootEl.remove();
49 | testCaseMap.set(name, rootEl);
50 | }
51 | });
52 |
53 | // Clean up just in case `useTestCases` is called in multiple `describe`
54 | // blocks.
55 | afterAll(() => {
56 | testCaseMap.clear();
57 | });
58 | }
59 |
60 | /**
61 | * Wraps a Jasmine test callback and scopes it to a specific test case DOM
62 | * element. This will render the test case to the document body and invoke the
63 | * callback with a reference to the element.
64 | *
65 | * @param name The test case name to render.
66 | * @param callback The Jasmine test implementation to test the DOM.
67 | */
68 | export function testCase(
69 | name: string,
70 | callback: (el: Element) => ReturnType,
71 | ): () => ReturnType {
72 | return async () => {
73 | if (testCaseMap.size === 0) {
74 | throw new Error('No test cases were found on the page. Did you forget to call `useTestCases`?');
75 | }
76 |
77 | // Find the associated test case.
78 | const testCase = testCaseMap.get(name);
79 | if (!testCase) {
80 | throw new Error(`No test case named \`${
81 | name}\`. Did you forget to add a \`\` element?`);
82 | }
83 |
84 | // Clone the test case element in case it is used and mutated by multiple
85 | // tests.
86 | const cloneFragment =
87 | testCase.content.cloneNode(true /* deep */) as DocumentFragment;
88 | const [ cloneRoot ] = cloneFragment.children;
89 | if (!cloneRoot || cloneFragment.children.length > 1) {
90 | throw new Error(`Test case \`${
91 | name}\` must contain exactly *one* root element.`);
92 | }
93 | document.body.appendChild(cloneRoot);
94 |
95 | // Invoke the underlying test.
96 | let result: ReturnType;
97 | try {
98 | result = await callback(cloneRoot);
99 | } finally {
100 | // Clean up the test case.
101 | cloneRoot.remove();
102 | }
103 |
104 | return result;
105 | };
106 | }
107 |
--------------------------------------------------------------------------------
/src/testing/timing.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `timing` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/testing/timing.test.ts:
--------------------------------------------------------------------------------
1 | import { nextFrame } from './timing.js';
2 |
3 | describe('timing', () => {
4 | describe('nextFrame', () => {
5 | it('waits for the next animation frame to complete', async () => {
6 | const spy = jasmine.createSpy<() => void>('callback');
7 |
8 | requestAnimationFrame(spy);
9 |
10 | await nextFrame();
11 |
12 | expect(spy).toHaveBeenCalled();
13 | });
14 |
15 | it('waits for RAF callbacks scheduled after `nextFrame`', async () => {
16 | const spy = jasmine.createSpy<() => void>('callback');
17 |
18 | const promise = nextFrame();
19 | requestAnimationFrame(spy);
20 |
21 | await promise;
22 |
23 | expect(spy).toHaveBeenCalled();
24 | });
25 |
26 | it('waits no more than *one* animation frame', async () => {
27 | const spy = jasmine.createSpy<() => void>('callback');
28 |
29 | // Schedule `spy` to be invoked two RAFs from now.
30 | requestAnimationFrame(() => { requestAnimationFrame(spy); });
31 |
32 | await nextFrame();
33 | expect(spy).not.toHaveBeenCalled();
34 |
35 | await nextFrame();
36 | expect(spy).toHaveBeenCalled();
37 | });
38 |
39 | describe('with mocked clock', () => {
40 | beforeEach(() => { jasmine.clock().install(); });
41 | afterEach(() => { jasmine.clock().uninstall(); });
42 |
43 | it('works', async () => {
44 | const spy = jasmine.createSpy<() => void>('callback');
45 |
46 | requestAnimationFrame(spy);
47 |
48 | await nextFrame();
49 |
50 | expect(spy).toHaveBeenCalled();
51 | });
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/testing/timing.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns a {@link Promise} which resolves after the next frame is rendered,
3 | * meaning at least one macrotask after all active {@link requestAnimationFrame}
4 | * callbacks have been invoked.
5 | */
6 | export async function nextFrame(): Promise {
7 | return new Promise((resolve) => {
8 | requestAnimationFrame(() => {
9 | // Current in an animation frame, but other RAF calls may still be
10 | // pending if they were initially scheduled after this one. Therefore we
11 | // wait one additional macrotask to make sure all RAF calls in this batch
12 | // have executed. We can't use `queueMicrotask` here because RAF appears
13 | // to flush all pending microtasks between callbacks. Instead we use
14 | // `setTimeout` to create a macrotask.
15 | setTimeout(() => {
16 | resolve();
17 | });
18 |
19 | // If Jasmine's mock clock is enabled, we'll need to manually tick to
20 | // trigger the above `setTimeout`.
21 | if (globalThis.jasmine) {
22 | try {
23 | globalThis.jasmine.clock().tick(0);
24 | } catch { /* Clock may not be installed. */ }
25 | }
26 | });
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/src/utils/casing.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `casing` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/utils/casing.test.ts:
--------------------------------------------------------------------------------
1 | import { skewerCaseToPascalCase } from './casing.js';
2 |
3 | describe('casing', () => {
4 | describe('skewerCaseToPascalCase', () => {
5 | it('converts `skewer-case` inputs to `PascalCase`', () => {
6 | expect(skewerCaseToPascalCase('foo-bar-baz')).toBe('FooBarBaz');
7 | });
8 |
9 | it('ignores leading and trailing dashes', () => {
10 | expect(skewerCaseToPascalCase('---foo-bar---')).toBe('FooBar');
11 | });
12 |
13 | it('collapses duplicate dashes', () => {
14 | expect(skewerCaseToPascalCase('foo------bar')).toBe('FooBar');
15 | });
16 |
17 | it('converts inputs without a dash', () => {
18 | expect(skewerCaseToPascalCase('foo')).toBe('Foo');
19 | });
20 |
21 | it('converts inputs with a one-character word', () => {
22 | expect(skewerCaseToPascalCase('a-b-c')).toBe('ABC');
23 | });
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/utils/casing.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Collection of utilities around naming and casing. */
2 |
3 | /**
4 | * Converts a `skewer-case` term into `PascalCase` format.
5 | *
6 | * @param skewerCase A string in `skewer-case` format.
7 | * @returns The input in `PascalCase` format.
8 | */
9 | export function skewerCaseToPascalCase(skewerCase: string): string {
10 | return skewerCase.split('-')
11 | .map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
12 | .join('');
13 | }
14 |
--------------------------------------------------------------------------------
/src/utils/on-demand-definitions.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `on-demand-definitions` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/utils/on-demand-definitions.test.ts:
--------------------------------------------------------------------------------
1 | import '@webcomponents/scoped-custom-element-registry';
2 |
3 | import { createDefine, defineIfSupported } from './on-demand-definitions.js';
4 |
5 | describe('on-demand-definitions', () => {
6 | describe('defineIfSupported', () => {
7 | it('calls static `define` on a supporting class', () => {
8 | class MyElement extends HTMLElement {
9 | static define = jasmine.createSpy<() => void>('define');
10 | }
11 |
12 | defineIfSupported(MyElement);
13 |
14 | expect(MyElement.define).toHaveBeenCalledOnceWith();
15 | });
16 |
17 | it('ignores classes which do not implement the protocol', () => {
18 | class MyElement extends HTMLElement {
19 | // No `define` property.
20 | // static define(): void { /* ... */ }
21 | }
22 |
23 | expect(() => defineIfSupported(MyElement)).not.toThrow();
24 | });
25 | });
26 |
27 | describe('createDefine', () => {
28 | it('defines in the global registry', () => {
29 | class MyElement extends HTMLElement {
30 | static define = createDefine('on-demand--global-reg', this);
31 | }
32 |
33 | expect(customElements.get('on-demand--global-reg')).toBeUndefined();
34 |
35 | MyElement.define();
36 |
37 | expect(customElements.get('on-demand--global-reg')).toBe(MyElement);
38 | });
39 |
40 | it('no-ops when called multiple times', () => {
41 | class MyElement extends HTMLElement {
42 | static define = createDefine('on-demand--multi', this);
43 | }
44 |
45 | MyElement.define();
46 | expect(() => MyElement.define()).not.toThrow();
47 | });
48 |
49 | it('no-ops when `customElements.define` was already called', () => {
50 | class MyElement extends HTMLElement {
51 | static define = createDefine('on-demand--already-defined', this);
52 | }
53 |
54 | customElements.define('on-demand--already-defined', MyElement);
55 |
56 | expect(() => MyElement.define()).not.toThrow();
57 | });
58 |
59 | it('throws when the element was already defined with a different class', () => {
60 | class MyElement extends HTMLElement {
61 | static define = createDefine('on-demand--conflict', this);
62 | }
63 |
64 | customElements.define(
65 | 'on-demand--conflict', class extends HTMLElement {});
66 |
67 | expect(() => MyElement.define()).toThrowError(/already defined/);
68 | });
69 |
70 | it('passes through element definition options', () => {
71 | class MyElement extends HTMLParagraphElement {
72 | static define = createDefine('on-demand--options', this, {
73 | extends: 'p',
74 | });
75 | }
76 |
77 | MyElement.define();
78 |
79 | const p = document.createElement('p', {
80 | is: 'on-demand--options',
81 | });
82 | expect(p).toBeInstanceOf(MyElement);
83 | });
84 |
85 | it('throws when given a tag name on the global registry', () => {
86 | class MyElement extends HTMLElement {
87 | static define = createDefine('on-demand--global-tag', this);
88 | }
89 |
90 | // Omitting registry throws.
91 | expect(() => MyElement.define(undefined, 'on-demand--new-tag')).toThrow();
92 | expect(customElements.get('on-demand--new-tag')).toBeNull();
93 |
94 | // Explicitly providing default registry throws.
95 | expect(() => MyElement.define(customElements, 'on-demand--new-tag'))
96 | .toThrow();
97 | expect(customElements.get('on-demand--new-tag')).toBeNull();
98 | });
99 |
100 | it('defines the element on a provided scoped registry', () => {
101 | class MyElement extends HTMLElement {
102 | static define = createDefine('on-demand--scoped', this);
103 | }
104 |
105 | const registry = new CustomElementRegistry();
106 | MyElement.define(registry);
107 |
108 | expect(registry.get('on-demand--scoped')).toBe(MyElement);
109 | expect(customElements.get('on-demand--scoped')).toBeNull();
110 | });
111 |
112 | it('allows redefining the tag name in a scoped registry', () => {
113 | class MyElement extends HTMLElement {
114 | static define = createDefine('on-demand--custom-tag', this);
115 | }
116 |
117 | const registry = new CustomElementRegistry();
118 | MyElement.define(registry, 'on-demand--different-tag');
119 |
120 | expect(registry.get('on-demand--different-tag')).toBe(MyElement);
121 | expect(registry.get('on-demand--custom-tag')).toBeNull();
122 |
123 | expect(customElements.get('on-demand--different-tag')).toBeNull();
124 | expect(customElements.get('on-demand--custom-tag')).toBeNull();
125 | });
126 | });
127 | });
128 |
--------------------------------------------------------------------------------
/src/utils/on-demand-definitions.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Provides primitives to easily implement the on-demand
3 | * definitions community protocol.
4 | *
5 | * @see https://github.com/webcomponents-cg/community-protocols/pull/67
6 | */
7 |
8 | /**
9 | * Defines the custom element.
10 | *
11 | * @param registry The registry to define the custom element in. Defaults to the
12 | * global {@link customElements} registry.
13 | * @param tagName The tag name to define the custom element as. Uses a default
14 | * tag name when not specified. Using an explicit tag name is only supported
15 | * when using a non-global registry
16 | */
17 | export type Define =
18 | (registry?: CustomElementRegistry, tagName?: string) => void;
19 |
20 | /**
21 | * A class definition which implements the on-demand definitions community
22 | * protocol.
23 | *
24 | * Note that because `define` is static, this type should be applied to the
25 | * custom element class type, not the instance type.
26 | *
27 | * ```typescript
28 | * class MyElement extends HTMLElement {
29 | * static define() { ... }
30 | * }
31 | *
32 | * const definable = MyElement as Defineable;
33 | * ```
34 | */
35 | export interface Defineable {
36 | /**
37 | * Defines the custom element.
38 | *
39 | * @param registry The registry to define the custom element in. Defaults to
40 | * the global {@link customElements} registry.
41 | * @param tagName The tag name to define the custom element as. Uses a default
42 | * tag name when not specified. Using an explicit tag name is only
43 | * supported when a using non-global registry
44 | */
45 | define: Define;
46 | }
47 |
48 | /**
49 | * Defines the provided custom element in the global registry if that element
50 | * implements the on-demand definitions community protocol.
51 | *
52 | * @param Clazz The custom element class to define.
53 | */
54 | export function defineIfSupported(Clazz: typeof Element): void {
55 | (Clazz as Partial).define?.();
56 | }
57 |
58 | /**
59 | * Creates a {@link Define} function which defines the given custom element with
60 | * the default tag name. The returned function should be used as the static
61 | * `define` function in a {@link Defineable} custom element.
62 | *
63 | * @param defaultTagName The tag name to use in the global registry and by
64 | * default for scoped registries.
65 | * @param Clazz The custom element class to define.
66 | * @param options Options for the {@link CustomElementRegistry.prototype.define}
67 | * call.
68 | */
69 | export function createDefine(
70 | defaultTagName: string,
71 | Clazz: typeof HTMLElement,
72 | options?: ElementDefinitionOptions,
73 | ): Define {
74 | return (registry = customElements, tagName = defaultTagName) => {
75 | // Tag name can only be modified when not in the global registry.
76 | if (registry === customElements && tagName !== defaultTagName) {
77 | throw new Error('Cannot use a non-default tag name in the global custom element registry.');
78 | }
79 |
80 | // Check if the tag name was already defined by another class.
81 | const existing = registry.get(tagName);
82 | if (existing) {
83 | if (existing === Clazz) {
84 | return; // Already defined as the correct class, no-op.
85 | } else {
86 | throw new Error(`Tag name \`${tagName}\` already defined as \`${
87 | existing.name}\`.`);
88 | }
89 | }
90 |
91 | // Define the class.
92 | registry.define(tagName, Clazz, options);
93 | };
94 | }
95 |
--------------------------------------------------------------------------------
/src/utils/types.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `types` tests
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/utils/types.test.ts:
--------------------------------------------------------------------------------
1 | import { Class } from './types.js';
2 |
3 | describe('types', () => {
4 | describe('Class', () => {
5 | it('returns a class constructor of the given type', () => {
6 | // Type-only test, only needs to compile, not execute.
7 | expect().nothing();
8 | () => {
9 | class Foo {
10 | public __brand = 'foo';
11 | }
12 |
13 | const Clazz = {} as Class;
14 |
15 | new Clazz() satisfies Foo;
16 | };
17 | });
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | /** @fileoverview Collection of simple type utilities. */
2 |
3 | /**
4 | * Analogous to `Class` in Java. Represents the class object of the given
5 | * instance type.
6 | */
7 | export type Class = { new(): Instance };
8 |
--------------------------------------------------------------------------------
/tsconfig.demo.json:
--------------------------------------------------------------------------------
1 | /** @fileoverview Config for the demo compilation. */
2 |
3 | {
4 | "extends": "./tsconfig.base.json",
5 | "include": ["src/demo/**/*.ts"],
6 | "exclude": ["src/**/*.test.ts"],
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | /** @fileoverview "Solution style" tsconfig for IDEs to manage multiple compilation units. */
2 |
3 | {
4 | "files": [],
5 | "references": [
6 | { "path": "./tsconfig.demo.json" },
7 | { "path": "./tsconfig.lib.json" },
8 | { "path": "./tsconfig.test.json" },
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | /** @fileoverview Config for the main library compilation. */
2 |
3 | {
4 | "extends": "./tsconfig.base.json",
5 | "include": ["src/**/*.ts"],
6 | "exclude": [
7 | "src/**/*.test.ts",
8 | "src/testing/**/*.ts",
9 | "src/demo/**.ts",
10 | ],
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | /** @fileoverview Config for the test compilation. */
2 | {
3 | "extends": "./tsconfig.base.json",
4 | "include": ["src/**/*.test.ts", "src/testing/**.ts"],
5 | "compilerOptions": {
6 | "types": ["jasmine"],
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/web-test-runner.config.mjs:
--------------------------------------------------------------------------------
1 | import * as fs from 'node:fs';
2 | import { createRequire } from 'node:module';
3 |
4 | const require = createRequire(import.meta.url);
5 | const jasminePath = require.resolve('jasmine-core/lib/jasmine-core/jasmine.js');
6 |
7 | // See: https://github.com/blueprintui/web-test-runner-jasmine/blob/d07dad01e9e287ea96c41c433c6f787f6170566a/src/index.ts
8 | const testRunner = `
9 |
10 |
13 |
14 |
15 |
18 | `.trim();
19 |
20 | const testFramework = {
21 | config: {
22 | defaultTimeoutInterval: 1_000,
23 | failSpecWithNoExpectations: true,
24 | autoCleanClosures: true,
25 | },
26 | };
27 |
28 | // TODO: Export this plugin (or a Mocha version) as WTR doesn't seem to make prerendered HTML tests easy.
29 | // See: https://modern-web.dev/docs/dev-server/writing-plugins/overview/
30 | const jasminePlugin = {
31 | name: 'jasmine',
32 | transform(ctx) {
33 | if (!ctx.response.is('html') || ctx.url === '/') return;
34 |
35 | return { body: ctx.body.replace('', () => `${testRunner}`) };
36 | },
37 | };
38 |
39 | /** @type {import('@web/test-runner').TestRunnerConfig} */
40 | export default {
41 | testFramework,
42 | rootDir: 'dist/',
43 | nodeResolve: true,
44 | plugins: [ jasminePlugin ],
45 | concurrency: 1,
46 | };
47 |
--------------------------------------------------------------------------------