Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/release-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ jobs:
publish:
needs: [decide, package]
if: needs.decide.outputs.publish == 'true'
runs-on: ubuntu-latest
runs-on: ubicloud-standard-2
strategy:
fail-fast: false
matrix:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-oxc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ jobs:
publish:
needs: [decide, package]
if: needs.decide.outputs.publish == 'true'
runs-on: ubuntu-latest
runs-on: ubicloud-standard-2
permissions:
contents: read
id-token: write
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-layouts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solid-layouts",
"version": "0.2.1",
"version": "0.2.2",
"description": "Logic in a .ts file, markup in a .layout.tsx file, presentation declared at the call site",
"license": "MIT",
"type": "module",
Expand Down
53 changes: 53 additions & 0 deletions packages/solid-layouts/src/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,59 @@ describe("defineComponent: children", () => {
expect(seen.children).toBe("press me");
dispose();
});

test("a reactive child keeps updating after the reading effect re-runs", () => {
// The children memo is built on first read rather than at set-up, so a
// layout's provider wraps it. But a memo created while an effect is
// running belongs to that effect, and an effect disposes what it owns
// before it re-runs. The consumer's `insert` is such an effect, so the
// first time it re-ran it disposed the resolved children and every
// reactive child under the Layout went dead — silently, with nothing
// thrown and the last-rendered DOM left in place.
//
// Under Solid 2 the blast radius is the whole application, because the
// components nest: one `<Show>` mounting in the same batch as an
// unrelated write froze every subsequent update on the page.
const [gate, setGate] = createSignal(0);
const [label, setLabel] = createSignal("first");
const rendered: string[] = [];

const Root = defineComponent({
recipe: button,
layout: ((stable: { children: JSX.Element }) => {
// Stands in for the consumer's `insert`: an effect that reads the
// children and re-runs when something else it tracks changes.
createRenderEffect(() => {
gate();
void stable.children;
});
return null;
}) as never,
});

const dispose = mount(Root, {
// An accessor, which is what a reactive child compiles to. `children()`
// calls it inside the memo, so it recomputes with the signal.
get children() {
return () => {
rendered.push(label());
return label();
};
},
});

expect(rendered).toEqual(["first"]);

setLabel("second");
expect(rendered).toEqual(["first", "second"]);

// Re-run the reading effect. This is the disposal the bug depended on.
setGate(1);

setLabel("third");
expect(rendered.at(-1)).toBe("third");
dispose();
});
});

describe("defineComponent: identity", () => {
Expand Down
30 changes: 28 additions & 2 deletions packages/solid-layouts/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import {
children as resolveChildren,
createContext,
createMemo,
getOwner,
useContext,
} from "solid-js";
import type { ComponentDefaults, UIConfig } from "./defaults.js";
import { globalDefaultsFor } from "./defaults.js";
import { __nextInstance, __slotId } from "./ids.js";
import type { Recipe } from "./recipe.js";
import { Dynamic, type JSX, createComponent, rest } from "./renderer.js";
import {
Dynamic,
type JSX,
createComponent,
ownChildren,
rest,
} from "./renderer.js";
import type { PropsOf, SlotAttrs, SlotsOf, StateOf } from "./types.js";

/**
Expand Down Expand Up @@ -347,12 +354,31 @@ export function defineComponent<
// it — which is what `<Alert>` hit, its indicator throwing "must be used
// within <Alert>" while sitting inside one. Deferring lets the read happen
// under the provider.
//
// Deferring alone is not enough, though, and `ownChildren` is the rest of
// it. A memo built while an effect is running belongs to that effect, and
// an effect disposes what it owns before it re-runs. The first read here
// comes from the consumer's `insert`, which then subscribes to the very
// memo it now owns — so the first time a child changed, the effect woke
// and disposed the children it was re-reading. Everything reactive under
// the component went dead, with nothing thrown and the last-rendered DOM
// left in place. Under Solid 2, where these components nest to the root,
// one `<Show>` mounting was enough to freeze an entire application.
//
// So the children need the read site's *context*, which is where a
// provider the layout wrapped them in lives, and the component's
// *lifetime*. Splitting those is the one thing each major spells
// differently, which is why it sits in the renderer beside the other.
const owner = getOwner();
let kids: (() => JSX.Element) | undefined;

const stable = {
slot,
get children() {
if (!kids) kids = resolveChildren(() => escape.children as JSX.Element);
if (!kids)
kids = ownChildren(owner, () =>
resolveChildren(() => escape.children as JSX.Element),
);
return kids();
},
} as LayoutStable<R>;
Expand Down
34 changes: 34 additions & 0 deletions packages/solid-layouts/src/renderer.solid-2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,37 @@ export const rest = (
props: Record<string, unknown>,
keys: readonly string[],
): Record<string, unknown> => omit(props, ...(keys as string[]));

/** What this module needs of a 2.0 owner. See `ownChildren`. */
type Owner2 = { _context: unknown };

const { createOwner, getOwner, runWithOwner } = solid as unknown as {
createOwner(): Owner2;
getOwner(): Owner2 | null;
runWithOwner<T>(owner: Owner2 | null, fn: () => T): T;
};

/**
* Resolve a component's children under an owner that inherits the caller's
* context but not its lifetime. See the 1.9 twin for why both halves matter.
*
* 2.0 does not spell it the same way. `createRoot`'s second argument is an
* options bag here rather than 1.9's detached owner, and the new root is
* parented to whatever is running, so that route would hand the children right
* back to the effect they must outlive. What 2.0 does have is a flatter model
* of context: an owner holds a plain snapshot object in `_context`, copied
* from its parent when it is created, and `getContext` reads that object
* directly rather than walking the chain.
*
* So the two halves are assembled rather than inherited together. The scope is
* created under the component, which makes the component's disposal the one
* that reaches it, and then its context snapshot is replaced with the read
* site's — the same field, assigned the same way, that `setContext` writes one
* key at a time.
*/
export const ownChildren = <T>(owner: unknown, build: () => T): T => {
const readSite = getOwner();
const scope = runWithOwner(owner as Owner2 | null, () => createOwner());
if (readSite) scope._context = readSite._context;
return runWithOwner(scope, build);
};
33 changes: 32 additions & 1 deletion packages/solid-layouts/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,38 @@
export type { JSX } from "solid-js";
export { Dynamic, createComponent } from "solid-js/web";

import { splitProps } from "solid-js";
import {
type Owner,
createRoot,
getOwner,
onCleanup,
runWithOwner,
splitProps,
} from "solid-js";

/**
* Resolve a component's children under an owner that inherits the caller's
* context but not its lifetime.
*
* Both halves are load-bearing, and they pull apart. Context has to come from
* the *read site*, because that is where a provider the layout wrapped these
* children in has been established. Lifetime has to come from the *component*,
* because the read site is the consumer's `insert`, a computation that
* re-runs — and it subscribes to the very memo it would then own, so the first
* time a child changed it disposed the children it was re-reading. That is the
* "children update once, then freeze" bug, and under Solid 2 it took the whole
* application's reactivity with it.
*
* 1.9 separates the two with `createRoot`'s second argument, which sets the
* new root's owner without handing the root to it. Disposal is then ours to
* place, so it rides on the component. See the 2.0 twin for how that major
* spells the same thing.
*/
export const ownChildren = <T>(owner: Owner | null, build: () => T): T =>
createRoot((dispose) => {
if (owner) runWithOwner(owner, () => onCleanup(dispose));
return build();
}, getOwner() ?? undefined);

/**
* `props` without `keys`, still tracked.
Expand Down
Loading