diff --git a/packages/inula-novdom/src/components/Cond.tsx b/packages/inula-novdom/src/components/Cond.tsx index 17bb93e8..b9f826df 100644 --- a/packages/inula-novdom/src/components/Cond.tsx +++ b/packages/inula-novdom/src/components/Cond.tsx @@ -24,15 +24,13 @@ type Branch = JSXElement | (() => JSXElement); export interface CondProps { // Array of tuples, first item is the condition, second is the branch to render - branches: [CondExpression, Branch]; + branches: [CondExpression, Branch][]; } export function Cond(props: CondProps) { // Find the first branch that matches the condition // Any signal that used in condition expression, will trigger the condition to recompute const currentBranch = computed(() => { - // clean up the previous branch - for (let i = 0; i < props.branches.length; i++) { const [condition, branch] = props.branches[i]; if (typeof condition === 'function' ? condition() : condition) { diff --git a/packages/inula-novdom/src/components/Env.tsx b/packages/inula-novdom/src/components/Env.tsx new file mode 100644 index 00000000..1b125fb7 --- /dev/null +++ b/packages/inula-novdom/src/components/Env.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { mergeProps, splitProps } from '../util'; +import { Context, WithChildren } from '../type'; + +// Reactive scope for cleanup and context +export interface Scope { + context: T; +} + +// The current scope for the current render +let scope: Scope | null = null; + +export function runWithScope(fn: () => T, context: Context): T { + const prevScope = scope; + // Merge the context with the previous context for nested env + let mergedContext = context; + if (prevScope?.context) { + mergedContext = mergeProps(prevScope.context, context); + } + // Set the new scope + scope = { + context: mergedContext, + }; + try { + // Run the children with the new scope + return fn(); + } finally { + // Restore the previous scope + scope = prevScope; + } +} + +export function readContext() { + return scope?.context || {}; +} + +type EnvProps = WithChildren; + +/** + * @description The Env component is used to provide a context for the children components. + * @example + * type ThemeContext = { theme: string }; + * theme="dark"> + * + * + * When Comp is rendered, it can access the theme from the env. + * function Comp(props, context: UserContext & ThemeContext) { + * return

{theme}

+ * } + */ +export function Env(props: EnvProps) { + // splitProps is used to extract the children from the props. + // because when we directly access the children, we will trigger the children getter function. + const [childrenProp, context] = splitProps(props, ['children']); + + return runWithScope(() => childrenProp.children, context); +} diff --git a/packages/inula-novdom/src/core.ts b/packages/inula-novdom/src/core.ts index f42a6e90..9a6b53e3 100644 --- a/packages/inula-novdom/src/core.ts +++ b/packages/inula-novdom/src/core.ts @@ -15,12 +15,16 @@ import { insert } from './dom'; import { untrack } from 'inula-reactive'; +import { readContext } from './components/Env'; -type ComponentConstructor = (props: T) => any; +type ComponentConstructor = (props: T, context: any) => any; type CodeFunction = () => any; export function runComponent(Comp: ComponentConstructor, props: T = {} as T): any { - return untrack(() => Comp(props)); + return untrack(() => { + const context = readContext(); + return Comp(props, context); + }); } const ELEMENT_NODE = 1; diff --git a/packages/inula-novdom/src/type.ts b/packages/inula-novdom/src/type.ts index ab63f91a..da3ec6ad 100644 --- a/packages/inula-novdom/src/type.ts +++ b/packages/inula-novdom/src/type.ts @@ -15,5 +15,8 @@ // TODO: JSX type export type JSXElement = any; -export type FunctionComponent> = (props: Props) => unknown; +export type FunctionComponent

= (props: P) => unknown; export type AppDisposer = () => void; +export type Props = Record; +export type Context = Props; +export type WithChildren = T & { children?: JSXElement }; diff --git a/packages/inula-novdom/src/util.ts b/packages/inula-novdom/src/util.ts new file mode 100644 index 00000000..46602e16 --- /dev/null +++ b/packages/inula-novdom/src/util.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Props } from './type'; + +/** + * split props into different objects, should copy getter and setter descriptor. + * @example + * splitProps({a: 1, get b() { return 2 }}, ['a']) // => [{a: 1}, {get b() { return 2 }}] + */ +export function splitProps(props: Props, keys: string[]): [Props, Props] { + const target: Props = {}; + const others: Props = {}; + for (const propName of Object.getOwnPropertyNames(props)) { + copyProp(props, keys.includes(propName) ? target : others, propName); + } + return [target, others]; +} + +function copyProp(resource: Props, target: Props, key: string): void { + const desc = Object.getOwnPropertyDescriptor(resource, key)!; + if (!desc.get && !desc.set && desc.enumerable && desc.writable && desc.configurable) { + // if the prop is a plain value, we can directly assign it to the context + target[key] = desc.value; + } else { + Object.defineProperty(target, key, desc); + } +} + +/** + * merge two props into one object, including getter and setter descriptor. + * @param a + * @param b + * @example + * mergeProps({get a() { return 1 }}, {get b() { return 2 }}) // => {get a() { return 1 }, get b() { return 2 }} + */ +export function mergeProps(a: Props, b: Props): Props { + const target: Props = {}; + for (const propName of Object.getOwnPropertyNames(a)) { + copyProp(a, target, propName); + } + for (const propName of Object.getOwnPropertyNames(b)) { + copyProp(b, target, propName); + } + return target; +} diff --git a/packages/inula-novdom/tests/env.test.tsx b/packages/inula-novdom/tests/env.test.tsx new file mode 100644 index 00000000..eda3ba23 --- /dev/null +++ b/packages/inula-novdom/tests/env.test.tsx @@ -0,0 +1,412 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck For the compiled code. + +import { describe, expect } from 'vitest'; +import { domTest as it } from './utils'; +import { template as $$template, insert as $$insert } from '../src/dom'; +import { Cond } from '../src/components/Cond'; +import { runComponent as $$runComponent, render, render as $$render } from '../src/core'; +import { reactive } from 'inula-reactive'; +import { Env } from '../src/components/Env'; + +describe('env', () => { + it('should work.', ({container}) => { + /** + * 源码: + * const fn = vi.fn(); + * function App() { + * const x = reactive(7); + * const theme = reactive('dark'); + * return ( + * + * + * + * ); + * } + * + * function Child(props, context) { + * return

{context.theme}
; + * } + * render(() => , container); + */ + // 编译后: + const theme = reactive('dark'); + + function App() { + return $$runComponent(Env, { + get children() { + return $$runComponent(Child); + }, + get theme() { + return theme.get(); + }, + }); + } + + const _tmpl$ = /*#__PURE__*/ $$template(`
`); + + function Child(props, context) { + return (() => { + const _el$ = _tmpl$(); + $$insert(_el$, () => context.theme); + return _el$; + })(); + } + + render(() => $$runComponent(App, {}), container); + + expect(container.innerHTML).toBe('
dark
'); + theme.set('light'); + expect(container.innerHTML).toBe('
light
'); + }); + + it('should work with nested env.', ({container}) => { + /** + * 源码: + * const fn = vi.fn(); + * function App() { + * const theme = reactive('dark'); + * return ( + * + * + * + * ); + * } + * + * function Child(props, context) { + * return ( + * + * + * + * ); + * } + * + * function GrandChild(props, context) { + * return
{context.theme}
; + * } + * render(() => , container); + */ + // 编译后: + const theme = reactive('dark'); + + function App() { + return $$runComponent(Env, { + get children() { + return $$runComponent(Child); + }, + get theme() { + return theme.get(); + }, + }); + } + + const _tmpl$2 = /*#__PURE__*/ $$template(`
`); + + function Child(props, context) { + return $$runComponent(Env, { + get children() { + return $$runComponent(GrandChild); + }, + get theme() { + return context.theme; + }, + }); + } + + function GrandChild(props, context) { + return (() => { + const _el$ = _tmpl$2(); + $$insert(_el$, () => context.theme); + return _el$; + })(); + } + + render(() => $$runComponent(App, {}), container); + + expect(container.innerHTML).toBe('
dark
'); + theme.set('light'); + expect(container.innerHTML).toBe('
light
'); + }); + + it('should merged the parent env.', ({container}) => { + /** + * 源码: + * const fn = vi.fn(); + * function App() { + * const theme = reactive('dark'); + * return ( + * + * + * + * + * + * + * + * ); + * } + * function Child(props, context) { + * return
Theme: {context.theme}, Name: {context.name}, Id: {context.userInfo.id}
; + * } + */ + + // 编译后: + const theme = reactive('dark'); + + function App() { + return $$runComponent(Env, { + get children() { + return $$runComponent(Env, { + get children() { + return $$runComponent(Env, { + get children() { + return $$runComponent(Child); + }, + get userInfo() { + return {id: 1}; + }, + get name() { + return 'inula'; + }, + }); + }, + get name() { + return 'inula'; + }, + }); + }, + get theme() { + return theme.get(); + }, + }); + } + + const _tmpl$ = /*#__PURE__*/$$template(`
Theme: , Name: , Id: `); + + function Child(props, context) { + return (() => { + const _el$ = _tmpl$(), + _el$2 = _el$.firstChild, + _el$5 = _el$2.nextSibling, + _el$3 = _el$5.nextSibling, + _el$6 = _el$3.nextSibling, + _el$4 = _el$6.nextSibling; + $$insert(_el$, () => context.theme, _el$5); + $$insert(_el$, () => context.name, _el$6); + $$insert(_el$, () => context.userInfo.id, null); + return _el$; + })(); + } + + render(() => $$runComponent(App, {}), container); + expect(container.innerHTML).toBe('
Theme: dark, Name: inula, Id: 1
'); + }); + + it('should not be affected by parrallel env.', ({container}) => { + /** + * 源码: + * function App() { + * const theme = reactive('dark'); + * return ( + * <> + * + * + * + * + * + * + * + * ); + * } + * function Child(props, context) { + * return
{context.theme}
; + * } + * render(() => , container); + */ + + // 编译后: + const theme = reactive('dark'); + function App() { + return [ + $$runComponent(Env, { + get children() { + return $$runComponent(Child); + }, + get theme() { + return theme.get(); + }, + }), + $$runComponent(Env, { + get children() { + return $$runComponent(Child); + }, + theme: 'light', + }), + ]; + } + const _tmpl$ = /*#__PURE__*/ $$template(`
`); + function Child(props, context) { + return (() => { + const _el$ = _tmpl$(); + $$insert(_el$, () => context.theme); + return _el$; + })(); + } + render(() => $$runComponent(App, {}), container); + expect(container.innerHTML).toBe('
dark
light
'); + }); + + it('should work with recursive env, like menu.', ({container}) => { + /** + * 源码: + * const fn = vi.fn(); + * function App() { + * return ( + * + * + * + * + * + * + * + * + * + * + * + * + * + * ); + * } + * + * function Menu(props, context) { + * return ( + * + *
    + *
  • {props.key}
  • + * {props.children} + *
+ *
+ * ); + * } + * function SubMenu(props, context) { + * return ( + * + *
    + *
  • {props.key}
  • + * {props.children} + *
+ *
+ * ); + * } + * function MenuItem(props, context) { + * return
  • {[...context.path, props.key].join('-')}
  • ; + * } + * render(() => , container); + */ + + // 编译后: + + const _tmpl$ = /*#__PURE__*/ $$template(`
    • `), + _tmpl$2 = /*#__PURE__*/ $$template(`
    • `); + + function App() { + return $$runComponent(Menu, { + key: 'root', + get children() { + return [ + $$runComponent(SubMenu, { + key: 'sub1', + get children() { + return [ + $$runComponent(MenuItem, { + key: '1', + }), + $$runComponent(MenuItem, { + key: '2', + }), + ]; + }, + }), + $$runComponent(SubMenu, { + key: 'sub2', + get children() { + return [ + $$runComponent(MenuItem, { + key: '3', + }), + $$runComponent(SubMenu, { + key: 'sub3', + get children() { + return $$runComponent(MenuItem, { + key: '4', + }); + }, + }), + $$runComponent(MenuItem, { + key: '5', + }), + ]; + }, + }), + ]; + }, + }); + } + + function Menu(props, context) { + return $$runComponent(Env, { + get path() { + return [props.key]; + }, + get children() { + const _el$ = _tmpl$(), + _el$2 = _el$.firstChild; + $$insert(_el$2, () => props.key); + $$insert(_el$, () => props.children, null); + return _el$; + }, + }); + } + + function SubMenu(props, context) { + return $$runComponent(Env, { + get path() { + return [...context.path, props.key]; + }, + get children() { + const _el$3 = _tmpl$(), + _el$4 = _el$3.firstChild; + $$insert(_el$4, () => props.key); + $$insert(_el$3, () => props.children, null); + return _el$3; + }, + }); + } + + function MenuItem(props, context) { + return (() => { + const _el$5 = _tmpl$2(); + $$insert(_el$5, () => [...context.path, props.key].join('-')); + return _el$5; + })(); + } + + render(() => $$runComponent(App, {}), container); + expect(container.innerHTML).toMatchInlineSnapshot( + '
      • root
        • sub1
        • root-sub1-1
        • root-sub1-2
        • sub2
        • root-sub2-3
          • sub3
          • root-sub2-sub3-4
        • root-sub2-5
      ', + ); + }); +}); diff --git a/packages/inula-novdom/tests/render.test.tsx b/packages/inula-novdom/tests/render.test.tsx index d43b40b3..a45eb528 100644 --- a/packages/inula-novdom/tests/render.test.tsx +++ b/packages/inula-novdom/tests/render.test.tsx @@ -49,7 +49,7 @@ describe('render', () => { expect(container.querySelector('#count').innerHTML).toEqual('Count value is 0.'); }); - it('should render jsx with slots', ({ container }) => { + it('should render jsx expression with slots', ({ container }) => { /** * 源码: * const CountingComponent = () => { @@ -123,7 +123,7 @@ describe('render', () => { expect(container.querySelector('#count').innerHTML).toEqual('Count value is 1.'); }); - it('should render sub components', ({ container }) => { + it('should render components', ({ container }) => { /** * 源码: * const CountValue = (props) => { @@ -185,7 +185,81 @@ describe('render', () => { expect(container.querySelector('#count').innerHTML).toEqual('Count value is 1.'); }); - it('should render nested components', ({ container }) => { + it('should render components with slot', ({ container }) => { + /** + * 源码: + * const CountValue = (props) => { + * return
      Title: {props.children}
      ; + * } + * + * const CountingComponent = () => { + * const [count, setCount] = createSignal(0); + * const add = () => { + * setCount((c) => c + 1); + * } + * + * return
      + *

      FOO

      + *
      + *
      ; + * }; + * + * render(() => , document.getElementById("app")); + */ + + // 编译后: + const _tmpl$ = /*#__PURE__*/ $$template(`
      Title: `), + _tmpl$2 = /*#__PURE__*/ $$template(`

      Your count is .`), + _tmpl$3 = /*#__PURE__*/ $$template(`
      "` + ); + + container.querySelector('button').click(); + + expect(container.innerHTML).toMatchInlineSnapshot( + `"
      Title:

      Your count is 1.

      "` + ); + }); + + it('should render sub components', ({ container }) => { /** * 源码: * const CountValue = (props) => { diff --git a/packages/inula-reactive/src/RNode.ts b/packages/inula-reactive/src/RNode.ts index 6bf0aad1..04c3fbc8 100644 --- a/packages/inula-reactive/src/RNode.ts +++ b/packages/inula-reactive/src/RNode.ts @@ -306,7 +306,7 @@ export function runEffects(): void { } // 不进行响应式数据的使用追踪 -export function untrack(fn: () => void) { +export function untrack(fn: () => T): T { if (runningRNode === null) { return fn(); } diff --git a/packages/inula-reactive/src/RNodeCreator.ts b/packages/inula-reactive/src/RNodeCreator.ts index faaff8dd..3ba29a5c 100644 --- a/packages/inula-reactive/src/RNodeCreator.ts +++ b/packages/inula-reactive/src/RNodeCreator.ts @@ -22,7 +22,8 @@ import { getRNodeVal } from './RNodeAccessor'; export function createReactive(raw?: T): Signal; export function createReactive(raw?: T): Signal; export function createReactive(raw?: T): Signal; -export function createReactive(raw?: T): Signal; +export function createReactive(raw?: T): Signal; +export function createReactive(raw?: T): Signal; export function createReactive | Array | symbol>(raw?: T): DeepReactive; export function createReactive(raw: T, deep = true): DeepReactive | Signal { // Function, Date, RegExp, null, undefined are simple signals diff --git a/packages/inula-reactive/src/index.ts b/packages/inula-reactive/src/index.ts index f7c0588d..66fd2a00 100644 --- a/packages/inula-reactive/src/index.ts +++ b/packages/inula-reactive/src/index.ts @@ -13,7 +13,7 @@ * See the Mulan PSL v2 for more details. */ -import { createComputed as computed, createReactive as reactive, createWatch as watch} from './RNodeCreator'; +import { createComputed as computed, createReactive as reactive, createWatch as watch } from './RNodeCreator'; import { isReactiveObj } from './Utils'; import { RNode, untrack, runEffects } from './RNode'; import { setScheduler } from './SetScheduler'; diff --git a/packages/inula-reactive/tests/watch.test.ts b/packages/inula-reactive/tests/watch.test.ts new file mode 100644 index 00000000..105892f2 --- /dev/null +++ b/packages/inula-reactive/tests/watch.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ +import { reactive, computed, watch } from '../src'; + +describe('watch', () => { + it('should not track the unused reactive', () => { + const cond = reactive(true); + const a = reactive(1); + const b = reactive(1); + const fn = jest.fn(); + watch(() => { + fn(); + if (cond.get()) { + a.get(); + } else { + b.get(); + } + }); + expect(fn).toBeCalledTimes(1); + a.set(2); + expect(fn).toBeCalledTimes(2); + b.set(2); + // should not trigger fn + expect(fn).toBeCalledTimes(2); + + cond.set(false); + expect(fn).toBeCalledTimes(3); + a.set(3); + // should not trigger fn + expect(fn).toBeCalledTimes(3); + b.set(3); + expect(fn).toBeCalledTimes(4); + }); +});