diff --git a/.changeset/merge-memo-unstable-output.md b/.changeset/merge-memo-unstable-output.md
new file mode 100644
index 000000000..4f2a35314
--- /dev/null
+++ b/.changeset/merge-memo-unstable-output.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/signals": patch
+---
+
+`UNSTABLE_MEMO_OUTPUT` no longer reports the memo `merge()` creates for a function source, such as a compiled conditional component spread (``). No one can pass that memo `equals`, so the warning had no fix (#4256). In observe builds the memo is now named `merge`. Production output is unchanged.
diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts
index ef86a4d5d..07847ad77 100644
--- a/packages/signals/src/core/attribution.ts
+++ b/packages/signals/src/core/attribution.ts
@@ -8,6 +8,7 @@ import {
import {
CONFIG_GUESS,
CONFIG_OVERRIDE,
+ CONFIG_KEY_BAG,
CONFIG_PLUMBING,
CONFIG_WIDE,
CONFIG_SUPERSEDES,
@@ -1778,6 +1779,8 @@ function checkUnstableOutput(el: Computed, prevValue: unknown, newValue: un
// typeof guard: an explicit `unstableMemos: undefined` in enable() options
// clobbers the default through the spread — treat any non-number as off.
if (typeof limit !== "number") return;
+ // merge()'s function-source memo: a key bag no user configures (#4256).
+ if (el._config & CONFIG_KEY_BAG) return;
const node = el as AttributedNode;
if (
prevValue === newValue || // paranoia: changed runs should never hit this
diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts
index 9cc3ed301..0529e796b 100644
--- a/packages/signals/src/core/constants.ts
+++ b/packages/signals/src/core/constants.ts
@@ -200,6 +200,13 @@ export const CONFIG_WIDE = 1 << 26;
* creation by dev builds only (the observe artifact mangles the option name,
* as it does `_wide`). */
export const CONFIG_SUPERSEDES = 1 << 27;
+/** Observe tiers only: the memo `merge()` creates for a function source — a
+ * key bag readers consume key by key, built by the framework (a compiled
+ * component spread) with options no user can reach. The attribution engine's
+ * UNSTABLE_MEMO_OUTPUT skips it: a fresh-but-equal bag is the idiom
+ * (`{...(on() ? { a } : {})}`), and the advice (`equals`) cannot be applied
+ * (#4256). Set through `_extraConfig` by observe builds only. */
+export const CONFIG_KEY_BAG = 1 << 28;
export const STATUS_NONE = 0;
export const STATUS_PENDING = 1 << 0;
diff --git a/packages/signals/src/store/utils.ts b/packages/signals/src/store/utils.ts
index a3586affe..087bf5851 100644
--- a/packages/signals/src/store/utils.ts
+++ b/packages/signals/src/store/utils.ts
@@ -1,4 +1,4 @@
-import { SUPPORTS_PROXY } from "../core/constants.js";
+import { CONFIG_KEY_BAG, SUPPORTS_PROXY } from "../core/constants.js";
import { createMemo } from "../signals.js";
import { $PROXY, $RECORD, $TARGET, ownEnumerableKeys } from "./types.js";
@@ -991,7 +991,13 @@ export function merge(...sources: T): Merge {
count++;
only = s;
if (typeof s === "function") {
- flattened[n] = createMemo(s as () => any) as any;
+ // Observe tiers name it and exempt it from UNSTABLE_MEMO_OUTPUT (see
+ // CONFIG_KEY_BAG); prod passes no options.
+ flattened[n] = (
+ __OBSERVE__
+ ? createMemo(s as () => any, { name: "merge", _extraConfig: CONFIG_KEY_BAG } as {})
+ : createMemo(s as () => any)
+ ) as any;
kinds[n++] = SOURCE_MEMO;
continue;
}
diff --git a/packages/signals/tests/attribution-unstable-merge-4256.test.ts b/packages/signals/tests/attribution-unstable-merge-4256.test.ts
new file mode 100644
index 000000000..025942370
--- /dev/null
+++ b/packages/signals/tests/attribution-unstable-merge-4256.test.ts
@@ -0,0 +1,48 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { attribution } from "../src/attribution.js";
+import { createEffect, createRoot, createSignal, flush, merge, OBSERVE } from "../src/index.js";
+import type { DiagnosticEvent } from "../src/core/dev.js";
+
+afterEach(() => {
+ attribution.disable();
+ flush();
+ vi.restoreAllMocks();
+});
+
+function captureUnstable() {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ attribution.enable({ log: false, hotRuns: false, hotTime: false, unstableMemos: 4 });
+ const events: DiagnosticEvent[] = [];
+ OBSERVE!.diagnostics.subscribe(e => {
+ if (e.code === "UNSTABLE_MEMO_OUTPUT") events.push(e);
+ });
+ return events;
+}
+
+// #4256: a compiled conditional component spread (``)
+// is `merge(() => …)`. The memo merge() wraps the function in returns a fresh
+// key bag each run, and no user can pass it `equals`.
+it("merge()'s function-source memo is not reported as unstable output (#4256)", () => {
+ const [n, setN] = createSignal(0, { name: "n" });
+ let seen: unknown;
+ createRoot(() => {
+ const props = merge(() => (n() >= 0 ? { tone: "info" } : {})) as { tone?: string };
+ createEffect(
+ () => props.tone,
+ v => {
+ seen = v;
+ },
+ { name: "reader" }
+ );
+ });
+ flush();
+
+ const events = captureUnstable();
+ for (let i = 1; i <= 8; i++) {
+ setN(i);
+ flush();
+ }
+
+ expect(seen).toBe("info");
+ expect(events).toHaveLength(0);
+});