diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index ef83d034d..1ed1e1ebd 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -8,7 +8,8 @@ import { ErrorContext, getContext, setContext, - runWithBoundaryErrorContext + runWithBoundaryErrorContext, + RevealGroupContext } from "./signals.js"; import { sharedConfig, NoHydrateContext } from "./shared.js"; import type { SSRTemplateObject, HydrationContext } from "./shared.js"; @@ -20,33 +21,8 @@ export type { HydrationContext, SSRTemplateObject } from "./shared.js"; // --- Reveal SSR coordination --- -export type ServerRevealGroup = { - id: string; - /** - * Register a child fragment (Loading) or composite child (inner Reveal). - * Returns `collapseFallback` (hide fallback visually, used for collapsed-sequential - * tail) and `held` (stash `revealFragments` swaps until the parent releases us). - * `held` only applies when the caller is a nested Reveal — Loadings ignore it. - */ - register( - key: string, - options?: { onActivate?: () => void } - ): { collapseFallback: boolean; held: boolean }; - /** Called by a child when its subtree is fully resolved. */ - onResolved(key: string): void; - /** - * Called by a nested Reveal when it becomes "minimally resolved" under its own - * order (together: fully resolved; sequential: first registered fragment resolved; - * natural: any fragment resolved). Loadings don't fire this — their `onResolved` - * implies minimal readiness at the same time. - */ - onMinimallyResolved?(key: string): void; -}; - -export const RevealGroupContext: Context = { - id: Symbol("RevealGroupContext"), - defaultValue: null -}; +export type { ServerRevealGroup } from "./signals.js"; +export { RevealGroupContext } from "./signals.js"; /** * Handles errors during SSR rendering. diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 1867ecfaa..5b666c6b3 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1608,6 +1608,21 @@ const ErrorContext: Context<((err: any) => void) | null> = { }; export { ErrorContext }; + +export type ServerRevealGroup = { + id: string; + register( + key: string, + options?: { onActivate?: () => void } + ): { collapseFallback: boolean; held: boolean }; + onResolved(key: string): void; + onMinimallyResolved?(key: string): void; +}; + +export const RevealGroupContext: Context = { + id: Symbol("RevealGroupContext"), + defaultValue: null +}; export function runWithBoundaryErrorContext( owner: Owner, render: () => T, @@ -1644,6 +1659,7 @@ export function createErrorBoundary( const ctx = sharedConfig.context; const parent = getOwner(); const owner = createOwner(); + setContext(RevealGroupContext, null, owner); const outputOwner = ctx ? createOwner() : undefined; // Partial template from a pass that went async. A retry pull must resume // these surviving holes (their owners and any async computations created diff --git a/packages/solid/test/server/reveal-ssr.spec.ts b/packages/solid/test/server/reveal-ssr.spec.ts index 739786fb6..5eb9e428c 100644 --- a/packages/solid/test/server/reveal-ssr.spec.ts +++ b/packages/solid/test/server/reveal-ssr.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach } from "vitest"; import { createRoot, createMemo, createRevealOrder } from "../../src/server/index.js"; import { ssrHandleError } from "../../src/server/hydration.js"; -import { Loading, Reveal } from "../../src/server/flow.js"; +import { Loading, Reveal, Errored } from "../../src/server/flow.js"; import { sharedConfig } from "../../src/server/shared.js"; // ---- Minimal SSR context infrastructure (mirrors ssr-async.spec.ts) ---- @@ -1560,4 +1560,57 @@ describe("Reveal SSR component", () => { revealed = mock.revealFragmentsCalls.flatMap(c => (Array.isArray(c) ? c : [c])); expect(revealed).toContain(keys[1]); }); + + test("Errored-wrapped Loading does not join ancestor Reveal group", async () => { + const mock = createMockSSRContext({ async: true }); + sharedConfig.context = mock.context; + + const d1 = deferred(); + const d2 = deferred(); + + createRoot( + () => { + Reveal({ + order: "together", + get children() { + return [ + Loading({ + fallback: "direct-fb", + get children() { + const data = createMemo(() => d1.promise); + return ssr(["
", "
"], () => data()) as any; + } + }), + Errored({ + fallback: "err-fb", + get children() { + return Loading({ + fallback: "wrapped-fb", + get children() { + const data = createMemo(() => d2.promise); + return ssr(["", ""], () => data()) as any; + } + }) as any; + } + }) + ] as any; + } + } as any); + }, + { id: "t" } + ); + + // Only the direct Loading should register as a fragment. + // The Errored-wrapped Loading must NOT register because the Errored + // severs RevealGroupContext for its children (matching the client). + expect(mock.registeredFragments.size).toBe(1); + + const entries = [...mock.registeredFragments.entries()]; + expect(entries[0][1].revealGroup).toBeDefined(); + + // The single registered fragment should resolve the group on its own. + d1.resolve("direct-val"); + await tick(); + expect(mock.revealFragmentsCalls.length).toBe(1); + }); });