diff --git a/docs/demos/animation/style.css b/docs/demos/animation/style.css index 22db4db08..e2cc71bd3 100644 --- a/docs/demos/animation/style.css +++ b/docs/demos/animation/style.css @@ -21,8 +21,7 @@ border: 2px solid #f00; border-radius: 50%; transform-origin: 50% 50%; - transition: all 250ms ease; - transition-property: width, height, margin; + transition: transform 250ms ease; pointer-events: none; overflow: hidden; font-size: 9px; @@ -35,9 +34,7 @@ } &.big { - width: 24px; - height: 24px; - margin: -13px 0 0 -13px; + transform: scale(2); } & > .label { diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index a3398ceda..558a1bb8b 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -1,5 +1,4 @@ import { options, Component } from "preact"; -import { useRef, useMemo, useEffect } from "preact/hooks"; import { signal, computed, @@ -16,6 +15,7 @@ import { PropertyUpdater, AugmentedComponent, AugmentedElement as Element, + Computed, } from "./internal"; export { signal, computed, batch, effect, Signal, type ReadonlySignal }; @@ -30,6 +30,10 @@ function hook(hookName: T, hookFn: HookFn) { options[hookName] = hookFn.bind(null, options[hookName] || (() => {})); } +// mini "hook slots" implementation +let slotIndex = 0; +let slots: (Signal | Computed | Effect)[] = []; + let currentComponent: AugmentedComponent | undefined; let finishUpdate: (() => void) | undefined; @@ -63,14 +67,8 @@ function createUpdater(update: () => void) { * @todo: in Preact 11, just decorate Signal with `type:null` */ function Text(this: AugmentedComponent, { data }: { data: Signal }) { - // hasComputeds.add(this); - - // Store the props.data signal in another signal so that - // passing a new signal reference re-runs the text computed: - const currentSignal = useSignal(data); - currentSignal.value = data; - - const s = useMemo(() => { + // Run this only the first time this component is rendered: + if (this._slots!.length === 0) { // mark the parent component as having computeds so it gets optimized let v = this.__v; while ((v = v.__!)) { @@ -84,13 +82,18 @@ function Text(this: AugmentedComponent, { data }: { data: Signal }) { this._updater!._callback = () => { (this.base as Text).data = s.peek(); }; + } - return computed(() => { - let data = currentSignal.value; - let s = data.value; - return s === 0 ? 0 : s === true ? "" : s || ""; - }); - }, []); + // Store the props.data signal in another signal so that + // passing a new signal reference re-runs the text computed: + const currentSignal = useSignal(data); + currentSignal.value = data; + + const s = useComputed(() => { + let data = currentSignal.value; + let s = data.value; + return s === 0 ? 0 : s === true ? "" : s || ""; + }); return s.value; } @@ -140,6 +143,12 @@ hook(OptionsTypes.RENDER, (old, vnode) => { let component = vnode.__c; if (component) { + slotIndex = 0; + slots = component._slots!; + if (slots === undefined) { + component._slots = slots = []; + } + component._updateFlags &= ~HAS_PENDING_UPDATE; updater = component._updater; @@ -244,10 +253,9 @@ function createPropUpdater( /** Unsubscribe from Signals when unmounting components/vnodes */ hook(OptionsTypes.UNMOUNT, (old, vnode: VNode) => { if (typeof vnode.type === "string") { - let dom = vnode.__e as Element | undefined; - // vnode._dom is undefined during string rendering + let dom = vnode.__e as Element; if (dom) { - const updaters = dom._updaters; + let updaters = dom._updaters; if (updaters) { dom._updaters = undefined; for (let prop in updaters) { @@ -259,11 +267,19 @@ hook(OptionsTypes.UNMOUNT, (old, vnode: VNode) => { } else { let component = vnode.__c; if (component) { - const updater = component._updater; + let updater = component._updater; if (updater) { component._updater = undefined; updater._dispose(); } + let slots = component._slots; + if (slots) { + component._slots = undefined; + for (let i = slots.length; i--; ) { + let slot = slots[i]; + if ((slot as Effect)._dispose) (slot as Effect)._dispose(); + } + } } } old(vnode); @@ -271,8 +287,7 @@ hook(OptionsTypes.UNMOUNT, (old, vnode: VNode) => { /** Mark components that use hook state so we can skip sCU optimization. */ hook(OptionsTypes.HOOK, (old, component, index, type) => { - if (type < 3) - (component as AugmentedComponent)._updateFlags |= HAS_HOOK_STATE; + if (type < 3) component._updateFlags |= HAS_HOOK_STATE; old(component, index, type); }); @@ -321,6 +336,9 @@ Component.prototype.shouldComponentUpdate = function ( // @ts-ignore for (let i in state) return true; + // if no new props were received, this is a purely Signal component: + // if (props === this.props) return false; + // if any non-Signal props changed, update: for (let i in props) { if (i !== "__source" && props[i] !== this.props[i]) return true; @@ -332,25 +350,35 @@ Component.prototype.shouldComponentUpdate = function ( }; export function useSignal(value: T) { - return useMemo(() => signal(value), []); + let slot = + (slots[slotIndex] as Signal) || (slots[slotIndex] = signal(value)); + slotIndex++; + return slot; } export function useComputed(compute: () => T) { - const $compute = useRef(compute); - $compute.current = compute; - (currentComponent as AugmentedComponent)._updateFlags |= HAS_COMPUTEDS; - return useMemo(() => computed(() => $compute.current()), []); + currentComponent!._updateFlags |= HAS_COMPUTEDS; + const slot = + (slots[slotIndex] as Computed) || + (slots[slotIndex] = computed(compute)); + slot._compute = compute; + slotIndex++; + return slot; } export function useSignalEffect(cb: () => void | (() => void)) { - const callback = useRef(cb); - callback.current = cb; - - useEffect(() => { - return effect(() => { - callback.current(); + let slot = slots[slotIndex] as Effect; + if (slot === undefined) { + effect(function (this: Effect) { + slot = this; + slots[slotIndex] = slot; + // register the effect to run on mount: + currentComponent!.__h.push(slot._callback.bind(slot)); }); - }, []); + } + // Update the Effect's callback to the newly-provided one: + slot._compute = cb; + slotIndex++; } /** diff --git a/packages/preact/src/internal.d.ts b/packages/preact/src/internal.d.ts index 436ac029a..3d48fc3de 100644 --- a/packages/preact/src/internal.d.ts +++ b/packages/preact/src/internal.d.ts @@ -1,13 +1,21 @@ import { Component } from "preact"; -import { Signal } from "@preact/signals-core"; +import { ReadonlySignal, Signal } from "@preact/signals-core"; export interface Effect { _sources: object | undefined; + /** The effect's user-defined callback */ + _compute(): void; + /** Begins an effectful read (returns the end() function) */ _start(): () => void; + /** Runs the effect */ _callback(): void; _dispose(): void; } +export interface Computed extends ReadonlySignal { + _compute: () => T; +} + export interface PropertyUpdater { _update: (newSignal: Signal, newProps: Record) => void; _dispose: () => void; @@ -18,7 +26,12 @@ export interface AugmentedElement extends HTMLElement { } export interface AugmentedComponent extends Component { + /** Component's most recent owner VNode */ __v: VNode; + /** _renderCallbacks */ + __h: (() => void)[]; + /** "mini-hooks" slots for useSignal/useComputed/useEffect */ + _slots?: (Signal | Computed | Effect)[]; _updater?: Effect; _updateFlags: number; } @@ -44,7 +57,11 @@ export const enum OptionsTypes { } export interface OptionsType { - [OptionsTypes.HOOK](component: Component, index: number, type: number): void; + [OptionsTypes.HOOK]( + component: AugmentedComponent, + index: number, + type: number + ): void; [OptionsTypes.DIFF](vnode: VNode): void; [OptionsTypes.DIFFED](vnode: VNode): void; [OptionsTypes.RENDER](vnode: VNode): void; diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx index f53b77962..8569e491d 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -1,5 +1,5 @@ -import { signal, useComputed } from "@preact/signals"; -import { createElement, render } from "preact"; +import { signal, useComputed, useSignalEffect } from "@preact/signals"; +import { createElement, createRef, render } from "preact"; import { setupRerender, act } from "preact/test-utils"; const sleep = (ms?: number) => new Promise(r => setTimeout(r, ms)); @@ -336,4 +336,56 @@ describe("@preact/signals", () => { }); }); }); + + describe("useSignalEffect()", () => { + it("should be invoked after commit", async () => { + const ref = createRef(); + const sig = signal("foo"); + const spy = sinon.spy(); + let count = 0; + + function App() { + useSignalEffect(() => + spy( + sig.value, + ref.current, + ref.current.getAttribute("data-render-id") + ) + ); + return ( +

+ {sig.value} +

+ ); + } + + render(, scratch); + expect(scratch.textContent).to.equal("foo"); + // expect(spy).not.to.have.been.called; + await sleep(1); + expect(spy).to.have.been.calledOnceWith( + "foo", + scratch.firstElementChild, + "0" + ); + + spy.resetHistory(); + + sig.value = "bar"; + rerender(); + + expect(scratch.textContent).to.equal("bar"); + await sleep(1); + + // NOTE: Ideally, call should receive "1" as its third argument! + // The "0" indicates that Preact's DOM mutations hadn't yet been performed when the callback ran. + // This happens because we do signal-based effect runs after the first, not VDOM. + // Perhaps we could find a way to defer the callback when it coincides with a render? + expect(spy).to.have.been.calledOnceWith( + "bar", + scratch.firstElementChild, + "0" // ideally "1" - update if we find a nice way to do so! + ); + }); + }); }); diff --git a/packages/preact/test/ssr.test.tsx b/packages/preact/test/ssr.test.tsx index 752f37550..cf5fa0c21 100644 --- a/packages/preact/test/ssr.test.tsx +++ b/packages/preact/test/ssr.test.tsx @@ -1,4 +1,9 @@ -import { signal, useSignal, useComputed } from "@preact/signals"; +import { + signal, + useSignal, + useComputed, + useSignalEffect, +} from "@preact/signals"; import { createElement } from "preact"; import { renderToString } from "preact-render-to-string"; @@ -147,5 +152,22 @@ describe("@preact/signals", () => { } expect(renderToString()).to.equal(`
012
`); }); + + it("should skip useSignalEffect()", async () => { + const spy = sinon.spy(); + function App() { + const b = useSignal(0); + useSignalEffect(() => spy(b.value)); + return ( +
+ {b.value} + {++b.value} +
+ ); + } + expect(renderToString()).to.equal(`
01
`); + await sleep(10); + expect(spy).not.to.have.been.called; + }); }); });