Skip to content

Commit

Permalink
Adds hydration params.
Browse files Browse the repository at this point in the history
This introduces a new `HTMLElementHydrationParamsMap` which maps tag names to class properties which be set as part of hydration. Intended usage looks like so:

```typescript
class MyElement extends HTMLElement {
  declare tagName: 'MY-ELEMENT'; // Necessary.

  public foo!: string;
  public bar!: number;
  public baz!: boolean;

  // ...
}

declare global {
  interface HTMLElementTagNameMap {
    'my-element': MyElement;
  }

  // Defines which properties are required and which are optional.
  interface HTMLElementHydrationParamsMap {
    'my-element': Properties<MyElement, {
      required: 'foo',
      optional: 'bar' | 'baz',
    }>;
  }
}
```

Then, when hydrating this element with `hydrate` or `Dehydrated.prototype.hydrate`, the params object *must* contain `foo` and *may* contain `bar` and `baz`.

```typescript
host.query('my-element').hydrate(MyElement, {
  foo: 'test',
  bar: 1234,
  baz: true,
});
```

Custom elements which do not define their parameters in `HTMLElementHydrationParamsMap` or which do not narrow the `tagName` property have all their properties considered optional.
  • Loading branch information
dgp1130 committed Dec 1, 2024
1 parent 24f32f0 commit a772872
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 6 deletions.
36 changes: 35 additions & 1 deletion src/dehydrated.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { Dehydrated } from './dehydrated.js';
import { ElementAccessor } from './element-accessor.js';
import { isHydrated } from './hydration.js';
import { isHydrated, Properties } from './hydration.js';
import { parseHtml } from './testing.js';

class ParamsElForTyping extends HTMLElement {
declare tagName: 'DEHYDRATED-PARAMS-CE';

foo!: string;
bar!: number;
baz!: boolean;
}

declare global {
interface HTMLElementHydrationParamsMap {
'dehydrated-params-ce': Properties<ParamsElForTyping, {
required: 'foo',
optional: 'bar' | 'baz',
}>;
}
}

describe('dehydrated', () => {
describe('Dehydrated', () => {
class DefinedElement extends HTMLElement {}
Expand Down Expand Up @@ -140,6 +157,23 @@ describe('dehydrated', () => {
ElementAccessor<HTMLDivElement>;
};
});

it('narrows the props type to hydration params', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const dehydrated = {} as Dehydrated<Element>;

dehydrated.hydrate(ParamsElForTyping, {
foo: 'test',
bar: 1234,
baz: true,

// @ts-expect-error Extra properties not allowed.
hello: 'world',
});
};
});
});

describe('query', () => {
Expand Down
14 changes: 10 additions & 4 deletions src/dehydrated.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Queryable } from './queryable.js';
import { ElementAccessor } from './element-accessor.js';
import { hydrate, isHydrated } from './hydration.js';
import { PropsOf, hydrate, isHydrated } from './hydration.js';
import { isCustomElement, isUpgraded } from './custom-elements.js';
import { QueryAllResult, QueryResult, QueryRoot } from './query-root.js';
import { Class } from './utils/types.js';

/**
* Represents a "dehydrated" reference to an element. The element is *not*
Expand Down Expand Up @@ -116,15 +117,20 @@ export class Dehydrated<out El extends Element> implements Queryable<El> {
*
* @param elementClass The class of the element to hydrate. This helps ensure
* that the custom element has been evaluated and defined.
* @param props Properties to assign to the element during hydration.
* @returns The underlying element, hydrated and wrapped in an
* {@link ElementAccessor} object.
* @throws If the element does not extend the provided class.
* @throws If the element is a custom element, but not upgraded.
* @throws If the element is already hydrated.
*/
public hydrate<HydrateEl extends El>(elementClass: { new(): HydrateEl }):
ElementAccessor<HydrateEl> {
hydrate(this.#native, elementClass);
public hydrate<Clazz extends Class<El>>(
elementClass: Clazz,
...[ props ]: {} extends PropsOf<InstanceType<Clazz>>
? [ props?: PropsOf<InstanceType<Clazz>> ]
: [ props: PropsOf<InstanceType<Clazz>> ]
): ElementAccessor<InstanceType<Clazz>> {
hydrate(this.#native, elementClass, props);
return ElementAccessor.from(this.#native);
}

Expand Down
191 changes: 190 additions & 1 deletion src/hydration.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,53 @@
import { hydrate, isHydrated } from './hydration.js';
import { hydrate, isHydrated, Properties } from './hydration.js';
import { NoopComponent } from './testing/noop-component.js';
import { testCase, useTestCases } from './testing/test-cases.js';

class ParamsElForTyping extends HTMLElement {
declare tagName: 'HYDRATION-PARAMS-CE';

foo!: string;
bar!: number;
baz!: boolean;
}

declare global {
interface HTMLElementHydrationParamsMap {
'hydration-params-ce': Properties<ParamsElForTyping, {
required: 'foo',
optional: 'bar' | 'baz',
}>;
}
}

class UntaggedElForTyping extends HTMLElement {
// Element *without* a tightly-defined `tagName` property.
// declare tagName: 'HYDRATION-UNTAGGED-CE';

foo!: string;
}

declare global {
// Unused due to lack of known tag name.
interface HTMLElementHydrationParamsMap {
'hydration-untagged-ce': Properties<UntaggedElForTyping, {
optional: 'foo',
}>;
}
}

class UndeclaredParamsElForTyping extends HTMLElement {
declare tagName: 'HYDRATION-UNDECLARED-CE';

foo!: string;
}

declare global {
interface HTMLElementHydrationParamsMap {
// Element *without* an entry in the property map.
// 'hydration-undeclared-ce': Properties<UndeclaredParamsElForTyping, {}>;
}
}

describe('hydrate', () => {
describe('isHydrated', () => {
customElements.define('hydrate-ce', class extends HTMLElement {});
Expand Down Expand Up @@ -73,6 +119,38 @@ describe('hydrate', () => {
expect((el as NoopComponent).hydrated).toBeTrue();
}));

it('applies the given parameters prior to hydration', () => {
let fooAtHydration: string | undefined = undefined;

class ParamsCE extends HTMLElement {
public foo!: string;

static readonly observedAttributes = [ 'defer-hydration' ];
attributeChangedCallback(
name: string,
_oldValue: string | null,
newValue: string | null,
): void {
if (name === 'defer-hydration' && newValue === null) this.hydrate();
}

private hydrate(): void {
fooAtHydration = this.foo;
}
}
customElements.define('params-ce', ParamsCE);

const el = document.createElement('params-ce') as ParamsCE;
el.setAttribute('defer-hydration', '');
document.body.append(el);

hydrate(el, ParamsCE, {
foo: 'test',
});

expect(fooAtHydration!).toBe('test');
});

it('throws an error when given the wrong class', testCase('deferred', (el) => {
class WrongComponent extends HTMLElement {}

Expand Down Expand Up @@ -115,5 +193,116 @@ describe('hydrate', () => {
el satisfies NoopComponent;
};
});

it('narrows the props type to hydration params', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const el = {} as Element;
hydrate(el, ParamsElForTyping, {
foo: 'test',
bar: 1234,
baz: true,

// @ts-expect-error Extra properties not allowed.
hello: 'world',
});
};
});

it('requires required params', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const el = {} as Element;

// @ts-expect-error Params object required.
hydrate(el, ParamsElForTyping);

// @ts-expect-error Required `foo` missing.
hydrate(el, ParamsElForTyping, {
bar: 1234,
baz: true,
});
};
});

it('does not require optional params', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const el = {} as Element;

hydrate(el, ParamsElForTyping, {
foo: 'test',
// Missing `bar` and `baz` are allowed.
});
};
});

it('type checks individual params', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const el = {} as Element;

hydrate(el, ParamsElForTyping, {
// @ts-expect-error Should be string.
foo: 1234,

// @ts-expect-error Should be number.
bar: 'test',

// @ts-expect-error Should be boolean.
baz: 'test',
});
};
});

it('considers all properties optional for untagged elements', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const el = {} as Element;

// No properties required.
hydrate(el, UntaggedElForTyping);
hydrate(el, UntaggedElForTyping, {});

hydrate(el, UntaggedElForTyping, {
// Can set any arbitrary properties.
foo: 'test',

// Even properties from the super class.
hidden: true,

// @ts-expect-error Unknown properties disallowed.
bar: 'test',
});
};
});

it('considers all properties optional for elements not in the map', () => {
// Type-only test, only needs to compile, not execute.
expect().nothing();
() => {
const el = {} as Element;

// No properties required.
hydrate(el, UndeclaredParamsElForTyping);
hydrate(el, UndeclaredParamsElForTyping, {});

hydrate(el, UndeclaredParamsElForTyping, {
// Can set any arbitrary properties.
foo: 'test',

// Even properties from the super class.
hidden: true,

// @ts-expect-error Unknown properties disallowed.
bar: 'test',
});
};
});
});
});
Loading

0 comments on commit a772872

Please sign in to comment.