├── .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 | 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 | 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 | 34 | 35 |
Goodbye
36 |
37 | 38 | 39 | 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 | 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 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hydroactive-component.test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | `hydroactive-component` tests 5 | 6 | 7 | 8 | 11 | 12 | 15 | 16 | 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 | 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 | 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 | 11 | 12 | 15 | 16 | 17 | 18 | 22 | 23 | 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 | * 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 \`