Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 4 additions & 28 deletions packages/solid/src/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<ServerRevealGroup | null> = {
id: Symbol("RevealGroupContext"),
defaultValue: null
};
export type { ServerRevealGroup } from "./signals.js";
export { RevealGroupContext } from "./signals.js";

/**
* Handles errors during SSR rendering.
Expand Down
16 changes: 16 additions & 0 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerRevealGroup | null> = {
id: Symbol("RevealGroupContext"),
defaultValue: null
};
export function runWithBoundaryErrorContext<T>(
owner: Owner,
render: () => T,
Expand Down Expand Up @@ -1644,6 +1659,7 @@ export function createErrorBoundary<T, U>(
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
Expand Down
55 changes: 54 additions & 1 deletion packages/solid/test/server/reveal-ssr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----
Expand Down Expand Up @@ -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<string>();
const d2 = deferred<string>();

createRoot(
() => {
Reveal({
order: "together",
get children() {
return [
Loading({
fallback: "direct-fb",
get children() {
const data = createMemo(() => d1.promise);
return ssr(["<div>", "</div>"], () => data()) as any;
}
}),
Errored({
fallback: "err-fb",
get children() {
return Loading({
fallback: "wrapped-fb",
get children() {
const data = createMemo(() => d2.promise);
return ssr(["<span>", "</span>"], () => 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);
});
});