diff --git a/package-lock.json b/package-lock.json index 57a18ca..3b53156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@web/test-runner-puppeteer": "^0.14.2", "http-server": "^14.1.1", "jasmine-core": "^4.5.0", - "typescript": "^4.9.4" + "typescript": "^5.3.3" } }, "node_modules/@75lb/deep-merge": { @@ -5039,16 +5039,16 @@ } }, "node_modules/typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/typical": { @@ -9172,9 +9172,9 @@ } }, "typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true }, "typical": { diff --git a/package.json b/package.json index 941bb47..751d13f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@web/test-runner-puppeteer": "^0.14.2", "http-server": "^14.1.1", "jasmine-core": "^4.5.0", - "typescript": "^4.9.4" + "typescript": "^5.3.3" }, "files": [ "/*.js", diff --git a/src/component-class.ts b/src/component-class.ts new file mode 100644 index 0000000..0ac811d --- /dev/null +++ b/src/component-class.ts @@ -0,0 +1,38 @@ +import { ComponentRef } from './component-ref.js'; +import { ElementRef } from './element-ref.js'; +import { HydroActiveComponent } from './hydroactive-component.js'; +import { Signal } from './signals.js'; + +/** TODO */ +export abstract class Component + extends HydroActiveComponent { + protected readonly ref = ElementRef.from(this); + protected readonly comp = ComponentRef._from(this.ref); + + #state: State | undefined; + protected get state(): State { + if (!this.#doneHydrating) { + throw new Error('`state` is not accessible during hydration.'); + } + + return this.#state!; + } + + protected get props(): Record> { + return new Proxy(this, { + get(target: Component, prop: string | symbol): Signal { + const propName = typeof prop === 'symbol' ? prop.description : prop; + const internalProp = `_${propName}`; + return (target as any)[internalProp]; + } + }) as unknown as Record>; + } + + #doneHydrating = false; + protected override hydrate(): void { + this.#state = this.onHydrate() ?? ({} as State); + this.#doneHydrating = true; + } + + protected abstract onHydrate(): State | undefined; +} diff --git a/src/demo/auto-counter.ts b/src/demo/auto-counter.ts index d3b318e..8378dd4 100644 --- a/src/demo/auto-counter.ts +++ b/src/demo/auto-counter.ts @@ -1,24 +1,57 @@ -import { component } from 'hydroactive'; -import { cached, signal } from 'hydroactive/signals.js'; - -/** Automatically increments the count over time. */ -export const AutoCounter = component('auto-counter', (comp) => { - const label = comp.host.query('span'); - const count = signal(Number(label.text)); - const doubleCount = cached(() => count() * 2); - - comp.connected(() => { - const id = setInterval(() => { - count.set(count() + 1); - }, 1_000); - - return () => { - clearInterval(id); - }; - }); - - comp.effect(() => { - label.native.textContent = count().toString(); - console.log(`Double count: ${doubleCount()}`); - }); -}); +import { Component } from 'hydroactive'; +import { WriteableSignal, property, signal } from 'hydroactive/signals.js'; + +// Need to define an interface. +interface State { + readonly count: WriteableSignal; +} + +class AutoCounter extends Component { + // Need to define the property twice to get an internal and external type. + @property() + public accessor incrementBy = 2; + + protected override onHydrate() { + const label = this.ref.query('span')!; + const count = signal(Number(label.text)); + + this.comp.connected(() => { + const id = setInterval(() => { + // Too easy to accidentally use `this.incrementBy` and get a + // non-reactive value. + + // No way to type `this.props` based on `@property` accessors? + count.set(count() + (this.props['incrementBy']!() as number)); + }, 1_000); + return () => clearInterval(id); + }); + + this.comp.effect(() => { + label.native.textContent = count().toString(); + }); + + // Can't use `this.state` here or in any called function. + // `this.state.count()` -> Error. + // At least can do a decent error. + + // Not clear that this is the initial value of `this.state`. + return { count }; + } + + // Have to prefix `this.state` everywhere except the `hydrate` function. + public increment(): void { + this.state.count.set(this.state.count() + 1); + } + + public decrement(): void { + this.state.count.set(this.state.count() - 1); + } +} + +customElements.define('auto-counter', AutoCounter); + +declare global { + interface HTMLElementTagNameMap { + 'auto-counter': AutoCounter; + } +} diff --git a/src/index.ts b/src/index.ts index 7e58f82..a9aa945 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { HydrateLifecycle, component } from './component.js'; +export { Component } from './component-class.js'; export { ComponentRef, OnDisconnect, OnConnect } from './component-ref.js'; export { ElementRef } from './element-ref.js'; diff --git a/src/signals/index.ts b/src/signals/index.ts index 05e73b3..7c12f45 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -1,5 +1,6 @@ export { cached } from './cached.js'; export { effect } from './effect.js'; +export { property } from './property.js'; export { Equals, signal } from './signal.js'; export { MacrotaskScheduler } from './schedulers/macrotask-scheduler.js'; export { Action, CancelAction, Scheduler } from './schedulers/scheduler.js'; diff --git a/src/signals/property.ts b/src/signals/property.ts new file mode 100644 index 0000000..9b1b983 --- /dev/null +++ b/src/signals/property.ts @@ -0,0 +1,28 @@ +import { Component } from 'hydroactive'; +import { Signal, WriteableSignal } from './types.js'; +import { signal } from './signal.js'; + +type SignalPropertyDecorator = ( + target: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext, +) => ClassAccessorDecoratorResult | void; + +export function property(): SignalPropertyDecorator { + return (_target, ctx) => { + const propName = typeof ctx.name === 'symbol' ? ctx.name.description : ctx.name; + const internalProp = `_${propName}`; + return { + init(value): void { + (this as any)[internalProp] = signal(value); + }, + + get(): unknown { + return ((this as any)[internalProp] as Signal)(); + }, + + set(value: unknown): void { + ((this as any)[internalProp] as WriteableSignal).set(value); + }, + }; + }; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 930e4d1..5a2968c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,8 +16,8 @@ "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ - "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ @@ -27,7 +27,7 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "es2020", /* Specify what module code is generated. */ + "module": "Node16", /* Specify what module code is generated. */ "rootDir": "src/", /* Specify the root folder within your source files. */ "moduleResolution": "Node16", /* Specify how TypeScript looks up a file from a given module specifier. */ "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */