diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml index 3d1a2b0..f4b7bb1 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -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: diff --git a/.github/workflows/release-oxc.yml b/.github/workflows/release-oxc.yml index 17cfd36..8b085c1 100644 --- a/.github/workflows/release-oxc.yml +++ b/.github/workflows/release-oxc.yml @@ -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 diff --git a/packages/solid-layouts/package.json b/packages/solid-layouts/package.json index e45cddb..5d72b6c 100644 --- a/packages/solid-layouts/package.json +++ b/packages/solid-layouts/package.json @@ -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", diff --git a/packages/solid-layouts/src/component.test.ts b/packages/solid-layouts/src/component.test.ts index 4349fbd..bf648e1 100644 --- a/packages/solid-layouts/src/component.test.ts +++ b/packages/solid-layouts/src/component.test.ts @@ -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 `` 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", () => { diff --git a/packages/solid-layouts/src/component.ts b/packages/solid-layouts/src/component.ts index 7674f28..7d6492a 100644 --- a/packages/solid-layouts/src/component.ts +++ b/packages/solid-layouts/src/component.ts @@ -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"; /** @@ -347,12 +354,31 @@ export function defineComponent< // it — which is what `` hit, its indicator throwing "must be used // within " 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 `` 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; diff --git a/packages/solid-layouts/src/renderer.solid-2.ts b/packages/solid-layouts/src/renderer.solid-2.ts index 6085a2c..9803574 100644 --- a/packages/solid-layouts/src/renderer.solid-2.ts +++ b/packages/solid-layouts/src/renderer.solid-2.ts @@ -36,3 +36,37 @@ export const rest = ( props: Record, keys: readonly string[], ): Record => 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(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 = (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); +}; diff --git a/packages/solid-layouts/src/renderer.ts b/packages/solid-layouts/src/renderer.ts index e2edf66..4fabc5c 100644 --- a/packages/solid-layouts/src/renderer.ts +++ b/packages/solid-layouts/src/renderer.ts @@ -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 = (owner: Owner | null, build: () => T): T => + createRoot((dispose) => { + if (owner) runWithOwner(owner, () => onCleanup(dispose)); + return build(); + }, getOwner() ?? undefined); /** * `props` without `keys`, still tracked.