From c3b6075b9f274ddfe3a4a9bffc2f59d4177dfb16 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Sat, 5 Jul 2025 09:31:20 +0200 Subject: [PATCH 1/3] Support signals without importing preact-hooks --- packages/preact/src/index.ts | 175 +++++++++++++++++------------- packages/preact/src/internal.d.ts | 13 +++ 2 files changed, 115 insertions(+), 73 deletions(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 11f124c51..0ce304f3b 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -1,5 +1,4 @@ import { options, Component, isValidElement, Fragment } from "preact"; -import { useRef, useMemo, useEffect } from "preact/hooks"; import { signal, computed, @@ -18,6 +17,7 @@ import { PropertyUpdater, AugmentedComponent, AugmentedElement as Element, + HookState, } from "./internal"; export { @@ -53,6 +53,7 @@ function hook(hookName: T, hookFn: HookFn) { let currentComponent: AugmentedComponent | undefined; let finishUpdate: (() => void) | undefined; +let setupTasks: AugmentedComponent[] = []; function setCurrentUpdater(updater?: Effect) { // end tracking for the current update: @@ -91,7 +92,7 @@ function SignalValue(this: AugmentedComponent, { data }: { data: Signal }) { const currentSignal = useSignal(data); currentSignal.value = data; - const [isText, s] = useMemo(() => { + const [isText, s] = useStoreOnce(() => { let self = this; // mark the parent component as having computeds so it gets optimized let v = this.__v; @@ -138,7 +139,7 @@ function SignalValue(this: AugmentedComponent, { data }: { data: Signal }) { }; return [isText, wrappedSignal]; - }, []); + }); // Rerender the component whenever `data.value` changes from a VNode // to another VNode, from text to a VNode, or from a VNode to text. @@ -210,6 +211,7 @@ hook(OptionsTypes.RENDER, (old, vnode) => { } } + currentHookIndex = 0; currentComponent = component; setCurrentUpdater(updater); } @@ -262,7 +264,16 @@ hook(OptionsTypes.DIFFED, (old, vnode) => { } } } + } else if (vnode.__c) { + let component = vnode.__c as AugmentedComponent; + if ( + component.__persistentState && + component.__persistentState._pendingSetup.length + ) { + queueSetupTasks(setupTasks.push(component)); + } } + old(vnode); }); @@ -326,8 +337,15 @@ hook(OptionsTypes.UNMOUNT, (old, vnode: VNode) => { component._updater = undefined; updater._dispose(); } + + const persistentState = component.__persistentState; + if (persistentState) { + // Cleanup all the stored effects + persistentState._list.forEach(invokeCleanup); + } } } + old(vnode); }); @@ -386,9 +404,8 @@ Component.prototype.shouldComponentUpdate = function ( export function useSignal(value: T, options?: SignalOptions): Signal; export function useSignal(): Signal; export function useSignal(value?: T, options?: SignalOptions) { - return useMemo( - () => signal(value, options as SignalOptions), - [] + return useStoreOnce(() => + signal(value, options as SignalOptions) ); } @@ -396,7 +413,7 @@ export function useComputed(compute: () => T, options?: SignalOptions) { const $compute = useRef(compute); $compute.current = compute; (currentComponent as AugmentedComponent)._updateFlags |= HAS_COMPUTEDS; - return useMemo(() => computed(() => $compute.current(), options), []); + return useStoreOnce(() => computed(() => $compute.current(), options)); } function safeRaf(callback: () => void) { @@ -434,6 +451,31 @@ function notifyEffects(this: Effect) { } } +let prevRaf: typeof options.requestAnimationFrame | undefined; +function queueSetupTasks(newQueueLength: number) { + if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) { + prevRaf = options.requestAnimationFrame; + (prevRaf || deferEffects)(flushSetup); + } +} + +/** + * After paint effects consumer. + */ +function flushSetup() { + let component; + while ((component = setupTasks.shift())) { + if (!component.__persistentState) continue; + try { + component.__persistentState._pendingSetup.forEach(invokeCleanup); + component.__persistentState._pendingSetup.forEach(invokeEffect); + component.__persistentState._pendingSetup = []; + } catch (e) { + component.__persistentState._pendingSetup = []; + } + } +} + function flushDomUpdates() { batch(() => { let inst: Effect | undefined; @@ -453,78 +495,65 @@ export function useSignalEffect(cb: () => void | (() => void)) { const callback = useRef(cb); callback.current = cb; - useEffect(() => { + useOnce(() => { return effect(function (this: Effect) { this._notify = notifyEffects; return callback.current(); }); - }, []); + }); } -/** - * @todo Determine which Reactive implementation we'll be using. - * @internal - */ -// export function useReactive(value: T): Reactive { -// return useMemo(() => reactive(value), []); -// } +let currentHookIndex = 0; +function getState(index: number): HookState { + if (!currentComponent) { + throw new Error("Hooks can only be called inside components"); + } -/** - * @internal - * Update a Reactive's using the properties of an object or other Reactive. - * Also works for Signals. - * @example - * // Update a Reactive with Object.assign()-like syntax: - * const r = reactive({ name: "Alice" }); - * update(r, { name: "Bob" }); - * update(r, { age: 42 }); // property 'age' does not exist in type '{ name?: string }' - * update(r, 2); // '2' has no properties in common with '{ name?: string }' - * console.log(r.name.value); // "Bob" - * - * @example - * // Update a Reactive with the properties of another Reactive: - * const A = reactive({ name: "Alice" }); - * const B = reactive({ name: "Bob", age: 42 }); - * update(A, B); - * console.log(`${A.name} is ${A.age}`); // "Bob is 42" - * - * @example - * // Update a signal with assign()-like syntax: - * const s = signal(42); - * update(s, "hi"); // Argument type 'string' not assignable to type 'number' - * update(s, {}); // Argument type '{}' not assignable to type 'number' - * update(s, 43); - * console.log(s.value); // 43 - * - * @param obj The Reactive or Signal to be updated - * @param update The value, Signal, object or Reactive to update `obj` to match - * @param overwrite If `true`, any properties `obj` missing from `update` are set to `undefined` - */ -/* -export function update( - obj: T, - update: Partial>, - overwrite = false -) { - if (obj instanceof Signal) { - obj.value = peekValue(update); - } else { - for (let i in update) { - if (i in obj) { - obj[i].value = peekValue(update[i]); - } else { - let sig = signal(peekValue(update[i])); - sig[KEY] = i; - obj[i] = sig; - } - } - if (overwrite) { - for (let i in obj) { - if (!(i in update)) { - obj[i].value = undefined; - } - } - } + const hooks = + currentComponent.__persistentState || + (currentComponent.__persistentState = { + _list: [], + _pendingSetup: [], + }); + + if (index >= hooks._list.length) { + hooks._list.push({}); + } + + return hooks._list[index]; +} + +function useStoreOnce(factory: () => T): T { + const state = getState(currentHookIndex++); + if (!state._stored) { + state._stored = true; + state._value = factory(); + } + return state._value; +} + +function useRef(initialValue: T): { current: T } { + return useStoreOnce(() => ({ current: initialValue })); +} + +function useOnce(callback: () => void | (() => void)): void { + const state = getState(currentHookIndex++); + if (!state._executed) { + state._executed = true; + state._value = callback; + currentComponent!.__persistentState._pendingSetup.push(state); + } +} + +function invokeEffect(hook: HookState): void { + if (hook._value) { + hook._cleanup = hook._value() || undefined; + } +} + +function invokeCleanup(hook: HookState): void { + if (hook._cleanup) { + hook._cleanup(); + hook._cleanup = undefined; } } -*/ diff --git a/packages/preact/src/internal.d.ts b/packages/preact/src/internal.d.ts index 19912ecf8..301ab0be1 100644 --- a/packages/preact/src/internal.d.ts +++ b/packages/preact/src/internal.d.ts @@ -27,6 +27,19 @@ export interface AugmentedComponent extends Component { __v: VNode; _updater?: Effect; _updateFlags: number; + __persistentState: SignalState; +} + +export interface HookState { + _executed?: boolean; + _stored?: boolean; + _value?: any; + _cleanup?: () => void; +} + +export interface SignalState { + _list: HookState[]; + _pendingSetup: HookState[]; } export interface VNode

extends preact.VNode

{ From 8101042884ab0ea8dc04cfa8f475a67e9ca090ce Mon Sep 17 00:00:00 2001 From: jdecroock Date: Sat, 5 Jul 2025 09:31:20 +0200 Subject: [PATCH 2/3] Support signals without importing preact-hooks --- packages/preact/src/index.ts | 14 ++++++++------ packages/preact/utils/src/index.ts | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 0ce304f3b..a19133276 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -92,7 +92,7 @@ function SignalValue(this: AugmentedComponent, { data }: { data: Signal }) { const currentSignal = useSignal(data); currentSignal.value = data; - const [isText, s] = useStoreOnce(() => { + const [isText, s] = useStoreValueOnce(() => { let self = this; // mark the parent component as having computeds so it gets optimized let v = this.__v; @@ -404,7 +404,7 @@ Component.prototype.shouldComponentUpdate = function ( export function useSignal(value: T, options?: SignalOptions): Signal; export function useSignal(): Signal; export function useSignal(value?: T, options?: SignalOptions) { - return useStoreOnce(() => + return useStoreValueOnce(() => signal(value, options as SignalOptions) ); } @@ -413,7 +413,9 @@ export function useComputed(compute: () => T, options?: SignalOptions) { const $compute = useRef(compute); $compute.current = compute; (currentComponent as AugmentedComponent)._updateFlags |= HAS_COMPUTEDS; - return useStoreOnce(() => computed(() => $compute.current(), options)); + return useStoreValueOnce(() => + computed(() => $compute.current(), options) + ); } function safeRaf(callback: () => void) { @@ -523,7 +525,7 @@ function getState(index: number): HookState { return hooks._list[index]; } -function useStoreOnce(factory: () => T): T { +export function useStoreValueOnce(factory: () => T): T { const state = getState(currentHookIndex++); if (!state._stored) { state._stored = true; @@ -532,8 +534,8 @@ function useStoreOnce(factory: () => T): T { return state._value; } -function useRef(initialValue: T): { current: T } { - return useStoreOnce(() => ({ current: initialValue })); +export function useRef(initialValue: T): { current: T } { + return useStoreValueOnce(() => ({ current: initialValue })); } function useOnce(callback: () => void | (() => void)): void { diff --git a/packages/preact/utils/src/index.ts b/packages/preact/utils/src/index.ts index 785d447b9..84483fbb1 100644 --- a/packages/preact/utils/src/index.ts +++ b/packages/preact/utils/src/index.ts @@ -1,7 +1,6 @@ import { ReadonlySignal, Signal } from "@preact/signals-core"; -import { useSignal } from "@preact/signals"; +import { useSignal, useStoreValueOnce } from "@preact/signals"; import { Fragment, createElement, JSX } from "preact"; -import { useMemo } from "preact/hooks"; interface ShowProps { when: Signal | ReadonlySignal; @@ -27,7 +26,7 @@ interface ForProps { } export function For(props: ForProps): JSX.Element | null { - const cache = useMemo(() => new Map(), []); + const cache = useStoreValueOnce(() => new Map()); let list = ( (typeof props.each === "function" ? props.each() : props.each) as Signal< Array @@ -60,6 +59,7 @@ export function useSignalRef(value: T): Signal & { current: T } { Object.defineProperty(ref, "current", refSignalProto); return ref; } + const refSignalProto = { configurable: true, get(this: Signal) { From b18697e7f5a9ac970ef326967692d0c7e991cd3c Mon Sep 17 00:00:00 2001 From: jdecroock Date: Sat, 5 Jul 2025 09:42:27 +0200 Subject: [PATCH 3/3] Add to mangle --- mangle.json | 9 +++++++++ packages/preact/src/index.ts | 17 ++++++++++------- packages/preact/src/internal.d.ts | 3 ++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/mangle.json b/mangle.json index b5127c0b6..a44ad851d 100644 --- a/mangle.json +++ b/mangle.json @@ -28,6 +28,15 @@ "props": { "cname": 6, "props": { + "$__persistentState": "__$p", + "$_list": "__", + "$_pendingSetup": "__h", + "$_cleanup": "__c", + "$_stateValue": "__", + "$_args": "__H", + "$_stored": "__s", + "$_renderCallbacks": "__h", + "$_skipEffects": "__s", "core: Node": "", "$_watched": "W", "$_unwatched": "Z", diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index a19133276..480ec33ca 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -270,6 +270,7 @@ hook(OptionsTypes.DIFFED, (old, vnode) => { component.__persistentState && component.__persistentState._pendingSetup.length ) { + console.log("f", component.__persistentState._pendingSetup); queueSetupTasks(setupTasks.push(component)); } } @@ -467,7 +468,8 @@ function queueSetupTasks(newQueueLength: number) { function flushSetup() { let component; while ((component = setupTasks.shift())) { - if (!component.__persistentState) continue; + console.log("flushSetup", component.__P, component.__persistentState); + if (!component.__persistentState || !component.__P) continue; try { component.__persistentState._pendingSetup.forEach(invokeCleanup); component.__persistentState._pendingSetup.forEach(invokeEffect); @@ -527,11 +529,11 @@ function getState(index: number): HookState { export function useStoreValueOnce(factory: () => T): T { const state = getState(currentHookIndex++); - if (!state._stored) { + if (!state._stored || (options as any)._skipEffects) { state._stored = true; - state._value = factory(); + state._stateValue = factory(); } - return state._value; + return state._stateValue; } export function useRef(initialValue: T): { current: T } { @@ -542,14 +544,15 @@ function useOnce(callback: () => void | (() => void)): void { const state = getState(currentHookIndex++); if (!state._executed) { state._executed = true; - state._value = callback; + state._stateValue = callback; currentComponent!.__persistentState._pendingSetup.push(state); } } function invokeEffect(hook: HookState): void { - if (hook._value) { - hook._cleanup = hook._value() || undefined; + console.log("invokeEffect", hook); + if (hook._stateValue) { + hook._cleanup = hook._stateValue() || undefined; } } diff --git a/packages/preact/src/internal.d.ts b/packages/preact/src/internal.d.ts index 301ab0be1..d0a7ebb5c 100644 --- a/packages/preact/src/internal.d.ts +++ b/packages/preact/src/internal.d.ts @@ -28,12 +28,13 @@ export interface AugmentedComponent extends Component { _updater?: Effect; _updateFlags: number; __persistentState: SignalState; + __P?: Element | Text | null; } export interface HookState { _executed?: boolean; _stored?: boolean; - _value?: any; + _stateValue?: any; _cleanup?: () => void; }