diff --git a/.changeset/fresh-panthers-explain.md b/.changeset/fresh-panthers-explain.md new file mode 100644 index 000000000..f8f4f7f44 --- /dev/null +++ b/.changeset/fresh-panthers-explain.md @@ -0,0 +1,5 @@ +--- +"@preact/signals-react-transform": patch +--- + +Remove top-level requirement from react-transform diff --git a/.changeset/lovely-moons-beam.md b/.changeset/lovely-moons-beam.md new file mode 100644 index 000000000..b45318a57 --- /dev/null +++ b/.changeset/lovely-moons-beam.md @@ -0,0 +1,5 @@ +--- +"@preact/signals-react": patch +--- + +Fix rendering signals as text when using react-transform diff --git a/packages/react-transform/src/index.ts b/packages/react-transform/src/index.ts index 5c83fa936..779bd642f 100644 --- a/packages/react-transform/src/index.ts +++ b/packages/react-transform/src/index.ts @@ -145,16 +145,12 @@ function isOptedOutOfSignalTracking(path: NodePath | null): boolean { function isComponentFunction(path: NodePath): boolean { return ( fnNameStartsWithCapital(path) && // Function name indicates it's a component - getData(path.scope, containsJSX) === true && // Function contains JSX - path.scope.parent === path.scope.getProgramParent() // Function is top-level + getData(path.scope, containsJSX) === true // Function contains JSX ); } function isCustomHook(path: NodePath): boolean { - return ( - fnNameStartsWithUse(path) && // Function name indicates it's a hook - path.scope.parent === path.scope.getProgramParent() - ); // Function is top-level + return fnNameStartsWithUse(path); // Function name indicates it's a hook } function shouldTransform( diff --git a/packages/react/runtime/src/auto.ts b/packages/react/runtime/src/auto.ts index b5d505ec7..90dcda22f 100644 --- a/packages/react/runtime/src/auto.ts +++ b/packages/react/runtime/src/auto.ts @@ -6,7 +6,7 @@ import { import React from "react"; import jsxRuntime from "react/jsx-runtime"; import jsxRuntimeDev from "react/jsx-dev-runtime"; -import { EffectStore, useSignals, wrapJsx } from "./index"; +import { EffectStore, wrapJsx, _useSignalsImplementation } from "./index"; export interface ReactDispatcher { useRef: typeof React.useRef; @@ -146,11 +146,15 @@ const dispatcherMachinePROD = createMachine({ ``` */ +export let isAutoSignalTrackingInstalled = false; + let store: EffectStore | null = null; let lock = false; let currentDispatcher: ReactDispatcher | null = null; function installCurrentDispatcherHook() { + isAutoSignalTrackingInstalled = true; + Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", { get() { return currentDispatcher; @@ -171,7 +175,7 @@ function installCurrentDispatcherHook() { isEnteringComponentRender(currentDispatcherType, nextDispatcherType) ) { lock = true; - store = useSignals(); + store = _useSignalsImplementation(); lock = false; } else if ( isExitingComponentRender(currentDispatcherType, nextDispatcherType) diff --git a/packages/react/runtime/src/index.ts b/packages/react/runtime/src/index.ts index 8a81cdb87..b5945d5e1 100644 --- a/packages/react/runtime/src/index.ts +++ b/packages/react/runtime/src/index.ts @@ -1,11 +1,19 @@ -import { signal, computed, effect, Signal } from "@preact/signals-core"; +import { + signal, + computed, + effect, + Signal, + ReadonlySignal, +} from "@preact/signals-core"; import { useRef, useMemo, useEffect } from "react"; import { useSyncExternalStore } from "use-sync-external-store/shim/index.js"; +import { isAutoSignalTrackingInstalled } from "./auto"; export { installAutoSignalTracking } from "./auto"; const Empty = [] as const; const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15 +const noop = () => {}; export function wrapJsx(jsx: T): T { if (typeof jsx !== "function") return jsx; @@ -113,6 +121,29 @@ function createEffectStore(): EffectStore { }; } +function createEmptyEffectStore(): EffectStore { + return { + effect: { + _sources: undefined, + _callback() {}, + _start() { + return noop; + }, + _dispose() {}, + }, + subscribe() { + return noop; + }, + getSnapshot() { + return 0; + }, + f() {}, + [symDispose]() {}, + }; +} + +const emptyEffectStore = createEmptyEffectStore(); + let finalCleanup: Promise | undefined; const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve()); @@ -120,7 +151,7 @@ const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve()); * Custom hook to create the effect to track signals used during render and * subscribe to changes to rerender the component when the signals change. */ -export function useSignals(): EffectStore { +export function _useSignalsImplementation(): EffectStore { clearCurrentStore(); if (!finalCleanup) { finalCleanup = _queueMicroTask(() => { @@ -145,7 +176,12 @@ export function useSignals(): EffectStore { * A wrapper component that renders a Signal's value directly as a Text node or JSX. */ function SignalValue({ data }: { data: Signal }) { - return data.value; + const store = useSignals(); + try { + return data.value; + } finally { + store.f(); + } } // Decorate Signals so React renders them as components. @@ -161,17 +197,22 @@ Object.defineProperties(Signal.prototype, { ref: { configurable: true, value: null }, }); -export function useSignal(value: T) { +export function useSignals(): EffectStore { + if (isAutoSignalTrackingInstalled) return emptyEffectStore; + return _useSignalsImplementation(); +} + +export function useSignal(value: T): Signal { return useMemo(() => signal(value), Empty); } -export function useComputed(compute: () => T) { +export function useComputed(compute: () => T): ReadonlySignal { const $compute = useRef(compute); $compute.current = compute; return useMemo(() => computed(() => $compute.current()), Empty); } -export function useSignalEffect(cb: () => void | (() => void)) { +export function useSignalEffect(cb: () => void | (() => void)): void { const callback = useRef(cb); callback.current = cb;