Skip to content

Commit

Permalink
refactor(core/renderer): Add DecoElement props type support in JSX
Browse files Browse the repository at this point in the history
  • Loading branch information
star-ll committed Jan 1, 2025
1 parent 308ae89 commit 7a60d29
Show file tree
Hide file tree
Showing 13 changed files with 114 additions and 56 deletions.
13 changes: 12 additions & 1 deletion packages/core/src/api/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ import { jsx as h, Fragment } from '@decoco/renderer';
import { nextTickApi } from './global-api';
import { DecoPlugin } from './plugin';

export class DecoElement extends DecoPlugin {
type DecoElementAttributes = JSX.IntrinsicAttributes & React.HTMLAttributes<DecoElement>;

export class DecoElement<T = DecoElementAttributes> extends DecoPlugin {
// jsx
static h = h;
static Fragment = Fragment;
props: T & DecoElementAttributes = {} as T & DecoElementAttributes;
context: any = {};
setState: () => void = () => {};
forceUpdate: () => void = () => {};
state: any = {};
refs: any = {};

// lifecycles
componentWillMount() {}
componentDidMount() {}
shouldComponentUpdate() {
Expand All @@ -17,6 +27,7 @@ export class DecoElement extends DecoPlugin {
adoptedCallback() {}
attributeChangedCallback?(name: string, oldValue: any, newValue: any) {}

// hooks
$nextTick(this: any, callback: Function) {
return nextTickApi.call(this, callback);
}
Expand Down
85 changes: 49 additions & 36 deletions packages/core/src/decorators/Component.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,22 @@
import { bindEscapePropSet, bindComponentFlag, parseElementAttribute } from '../utils/element';
import { bindEscapePropSet, bindComponentFlag } from '../utils/element';
import { jsx, render } from '@decoco/renderer';
import { escapePropSet, observe, StatePool } from '../reactive/observe';
import { observe, StatePool } from '../reactive/observe';
import { Effect, effectStack } from '../reactive/effect';
import { expToPath } from '../utils/share';
import { forbiddenStateAndPropKey } from '../utils/const';
import { callLifecycle, LifecycleCallback, LifeCycleList } from '../runtime/lifecycle';
import { createJob, queueJob } from '../runtime/scheduler';
import { doWatch } from './Watch';
import { doWatch, WatchCallback } from './Watch';
import { DecoPlugin } from '../api/plugin';
import { isObjectAttribute, isUndefined } from '../utils/is';
import { isDefined, isObjectAttribute, isString, isUndefined } from '../utils/is';
import { warn } from '../utils/error';
import { EventEmitter } from './Event';
import { applyDiff } from 'deep-diff';
import clone from 'rfdc';
import { DecoratorMetaKeys } from '../enums/decorators';
import { computed } from './Computed';

export interface DecoWebComponent {
[K: string | symbol]: any;
readonly uid: number;

componentWillMountList: LifecycleCallback[];
componentDidMountList: LifecycleCallback[];
shouldComponentUpdateList: LifecycleCallback[];
componentDidUpdateList: LifecycleCallback[];
connectedCallbackList: LifecycleCallback[];
disconnectedCallbackList: LifecycleCallback[];
attributeChangedCallbackList: LifecycleCallback[];
adoptedCallbackList: LifecycleCallback[];
}
import { DecoWebComponent } from '../types/index';
import { DecoElement } from 'src/api/instance';

interface ComponentDecoratorOptions {
// tag name
Expand Down Expand Up @@ -61,7 +49,7 @@ export default function Component(tagOrOptions: string | LegacyComponentOptions,
tag = tagOrOptions.tag;
}

const observedAttributes = target.prototype.__propKeys || [];
const observedAttributes = target.prototype.props || [];

if (customElements.get(tag)) {
warn(`custom element ${tag} already exists`);
Expand All @@ -75,6 +63,10 @@ export default function Component(tagOrOptions: string | LegacyComponentOptions,
`Invalid tag name: ${tag}. Tag names must start with a letter and contain only letters, digits, and hyphens.`,
);
}

// displayName is used to create html element in decoco-renderer
target.displayName = tag;

customElements.define(String(tag), getCustomElementWrapper(target, { tag, style, observedAttributes }));
};
}
Expand All @@ -84,10 +76,13 @@ type CustomElementWrapperOptions = {
style?: string | StyleSheet;
observedAttributes: string[];
};
function getCustomElementWrapper(target: any, { tag, style, observedAttributes }: CustomElementWrapperOptions): any {
function getCustomElementWrapper(
target: typeof DecoElement,
{ tag, style, observedAttributes }: CustomElementWrapperOptions,
): any {
return class WebComponent extends target implements DecoWebComponent {
uid = ++uid;

shadowRootLink: ShadowRoot;
__updateComponent: () => void;
__mounted = false;

Expand All @@ -105,7 +100,7 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }

constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRootLink = this.attachShadow({ mode: 'open' }); // save shadowRoot for close mode.

const componentUpdateEffect = new Effect(__updateComponent.bind(this));
function __updateComponent(this: WebComponent) {
Expand All @@ -130,6 +125,7 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
}
}
this.__updateComponent = __updateComponent.bind(this);
this.forceUpdate = this.__updateComponent;

bindComponentFlag(this);
bindEscapePropSet(this);
Expand Down Expand Up @@ -226,8 +222,9 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
return;
}
statePool.initState(this, Array.from(stateKeys));
Array.from(stateKeys.values()).forEach((name: any) => {
observe(this, name, this[name]);
Array.from(stateKeys.values()).forEach((name) => {
const propertyName = name as keyof DecoElement;
observe(this, propertyName, this[propertyName]);
});
}

Expand All @@ -244,17 +241,21 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }

statePool.initState(this, Array.from(propKeys));

Array.from(propKeys.keys()).forEach((name: any) => {
const attr = this.getAttribute(name);
observe(this, name, this.hasAttribute(name) && !isObjectAttribute(attr) ? attr : this[name], {
Array.from(propKeys.keys()).forEach((name: unknown) => {
if (!isString(name)) {
return;
}
const attr = this.getAttribute(name) as keyof DecoElement;
const propertyName = name as keyof DecoElement;
observe(this, name, this.hasAttribute(name) && !isObjectAttribute(attr) ? attr : this[propertyName], {
isProp: true,
deep: true,
autoDeepReactive: true,
});

// prop map to html attribute
if (!this.hasAttribute(name) && this[name] !== undefined && this[name] !== null) {
queueJob(createJob(() => this.setAttribute(name, this[name])));
if (!this.hasAttribute(name) && this[propertyName] !== undefined && this[propertyName] !== null) {
queueJob(createJob(() => this.setAttribute(name, this[propertyName]?.toString() || '')));
}
});
}
Expand Down Expand Up @@ -293,7 +294,10 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
}

for (const item of watchers) {
const { watchKeys, watchMethodName } = item;
const { watchKeys, watchMethodName } = item as {
watchKeys: string[];
watchMethodName: keyof DecoElement;
};
watchKeys.forEach((watchKey: string) => {
const { ctx, property } = expToPath(watchKey, this) || {};
if (!ctx || !property) {
Expand All @@ -302,6 +306,10 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
}

const watchCallback = this[watchMethodName];
if (!isDefined<WatchCallback>(watchCallback)) {
warn(`watchCallback ${watchMethodName} is undefined`);
return;
}
doWatch(this, watchCallback, ctx, property, statePool, item.options);
});
}
Expand All @@ -315,7 +323,8 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
for (const eventName of events.keys()) {
const eventInit = events.get(eventName);
const eventEmit = new EventEmitter({ ...eventInit });
eventEmit.setEventTarget(this as any);
eventEmit.setEventTarget(this);
// @ts-ignore
this[eventName] = eventEmit;
}
}
Expand All @@ -328,11 +337,11 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
}

domUpdate() {
let rootVnode = this.render();
let rootVnode: any = this.render();

// style
if (style instanceof CSSStyleSheet) {
this.shadowRoot.adoptedStyleSheets = [style];
this.shadowRootLink.adoptedStyleSheets = [style];
} else if (typeof style === 'string') {
if (Array.isArray(rootVnode)) {
rootVnode.unshift(jsx('style', {}, style));
Expand All @@ -341,18 +350,22 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
}
}

render(rootVnode, this.shadowRoot);
// TODO: fix type error
render(rootVnode, this.shadowRootLink as unknown as HTMLElement);
}

initLifecycle() {
this.connectedCallbackList.push(super.connectedCallback);
this.disconnectedCallbackList.push(super.disconnectedCallback);
this.attributeChangedCallbackList.push(super.attributeChangedCallback);
this.adoptedCallbackList.push(super.adoptedCallback);
this.componentWillMountList.push(super.componentWillMount);
this.componentDidMountList.push(super.componentDidMount);
this.shouldComponentUpdateList.push(super.shouldComponentUpdate);
this.componentDidUpdateList.push(super.componentDidUpdate);

if (isDefined<LifecycleCallback>(super.attributeChangedCallback)) {
this.attributeChangedCallbackList.push(super.attributeChangedCallback);
}
}

initStore() {
Expand All @@ -361,7 +374,7 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes }
return;
}

for (const propName of stores.keys()) {
for (const propName of stores.keys() as (keyof DecoElement)[]) {
const { store, getState } = stores.get(propName);
const storeState = getState(store.getState());
const obj = clone()(storeState);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/decorators/Prop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default function Prop() {
propKeys.add(propertyKey);
Reflect.defineMetadata('propKeys', propKeys, target);

Object.defineProperty(target, '__propKeys', {
Object.defineProperty(target, 'props', {
writable: true,
configurable: false,
value: Array.from(propKeys),
Expand Down
7 changes: 3 additions & 4 deletions packages/core/src/decorators/Watch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { DecoratorMetadata } from '../types';
import { createJob, queueJob, SchedulerJob } from '../runtime/scheduler';
import { createJob, queueJob } from '../runtime/scheduler';
import { Effect } from '../reactive/effect';
import { StatePool } from '../reactive/observe';
import { DecoWebComponent } from './Component';
import { DecoWebComponent } from '../types/index';

export interface WatchOptions {
once?: boolean;
Expand All @@ -26,7 +25,7 @@ export function doWatch(
instance: DecoWebComponent,
watchCallback: WatchCallback,
propertyCtx: any,
property: string | symbol,
property: string | number | symbol,
statePool: StatePool,
watchOptions: WatchOptions,
) {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/reactive/effect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StatePool } from './observe';
import { DecoWebComponent } from '../decorators/Component';
import { DecoWebComponent } from '../types/index';

export type EffectOptions = {
value?: any;
Expand Down Expand Up @@ -34,7 +34,7 @@ export class Effect {
return this.effect(...args);
}

captureSelf(target: any, name: string | symbol, instance?: any) {
captureSelf(target: any, name: string | number | symbol, instance?: any) {
const statePool: StatePool = Reflect.getMetadata('statePool', instance || target);
statePool.set(target, name, this);
}
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/reactive/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const isProxy = Symbol.for('isProxy');
// Define an interface that includes the symbol as a key
interface ProxyTarget {
[isProxy]?: boolean;
[key: string | symbol]: any;
[key: string | number | symbol]: any;
}

export function createReactive(targetElement: any, target: unknown, options: ObserverOptions = {}) {
Expand Down Expand Up @@ -78,7 +78,7 @@ export function escapePropSet(target: any, prop: string, value: any) {

export function observe(
target: any,
name: string | symbol,
name: string | number | symbol,
originValue: any,
options: ObserverOptions = { deep: true },
) {
Expand Down Expand Up @@ -125,7 +125,7 @@ export function observe(

export class StatePool {
private isInitState: boolean = false;
private store: WeakMap<object, Map<string | symbol, Set<Effect>>> = new WeakMap();
private store: WeakMap<object, Map<string | number | symbol, Set<Effect>>> = new WeakMap();

constructor() {}

Expand All @@ -145,7 +145,7 @@ export class StatePool {
// this.isInitState = true;
}

set(target: object, name: string | symbol, effect?: Effect) {
set(target: object, name: string | number | symbol, effect?: Effect) {
if (proxyMap.has(target)) {
// target is a proxy
target = proxyMap.get(target)!;
Expand All @@ -168,7 +168,7 @@ export class StatePool {
});
}

delete(target: object, name: string | symbol, effect?: Effect) {
delete(target: object, name: string | number | symbol, effect?: Effect) {
const depKeyMap = this.store.get(target);
if (!depKeyMap) {
warn(`${target} has no state ${String(name)}`);
Expand All @@ -193,7 +193,7 @@ export class StatePool {
}
}

notify(target: object, name: string | symbol) {
notify(target: object, name: string | number | symbol) {
const depKeyMap = this.store.get(target) || this.store.set(target, new Map()).get(target);
const deps = depKeyMap!.get(name) || depKeyMap!.set(name, new Set()).get(name);
deps?.forEach((effect: Effect) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function callLifecycle(target: any, lifecycle: LifeCycleList): CallLifecy
for (const lifecycleCallback of lifecycles) {
try {
const callbackResult = lifecycleCallback.call(target);
if (isPromise(callbackResult)) {
if (lifecycle === LifeCycleList.SHOULD_COMPONENT_UPDATE && isPromise(callbackResult)) {
throw new Error(`${lifecycle} callback must be sync`);
}
result.push(callbackResult);
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LifecycleCallback } from '../runtime/lifecycle';
import type { StatePool } from '../reactive/observe';

export type ObserverOptions = {
Expand All @@ -14,3 +15,17 @@ export type DecoratorMetadata = {
statePool: StatePool;
[key: string]: any;
};

export interface DecoWebComponent {
readonly uid: number;
shadowRootLink: ShadowRoot;

componentWillMountList: LifecycleCallback[];
componentDidMountList: LifecycleCallback[];
shouldComponentUpdateList: LifecycleCallback[];
componentDidUpdateList: LifecycleCallback[];
connectedCallbackList: LifecycleCallback[];
disconnectedCallbackList: LifecycleCallback[];
attributeChangedCallbackList: LifecycleCallback[];
adoptedCallbackList: LifecycleCallback[];
}
8 changes: 8 additions & 0 deletions packages/core/src/utils/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,12 @@ export const forbiddenStateAndPropKey = new Set([
'render',
'__isDecocoComponent__',
'escapePropSet',

// jsx
'props',
'context',
'setState',
'forceUpdate',
'state',
'refs',
]);
2 changes: 1 addition & 1 deletion packages/core/src/utils/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function isObject<T extends object = object>(value: unknown): value is T
return typeof value === 'object' && value !== null;
}

export function isPlainObject<K = unknown>(value: unknown): value is { [key: string | symbol]: K } {
export function isPlainObject<K = unknown>(value: unknown): value is { [key: string | number | symbol]: K } {
return getTypeof(value) === 'Object';
}

Expand Down
Loading

0 comments on commit 7a60d29

Please sign in to comment.