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
37 changes: 37 additions & 0 deletions .changeset/a-link-follows-the-element-the-page-shows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/builder": patch
"create-nextly-app": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/eslint-plugin": patch
"@nextlyhq/module-specifiers": patch
"nextly": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-mcp": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/ui": patch
---

A link saved in a pattern now points at the element the page actually shows.
When a selection held a visible renamed element and a hidden copy with the
same id (hidden by a visibility condition), a link from an unrelated block could
keep the old id, which nothing renders once the hidden copy is left off the
page. The visible element now decides where such a link points. A hidden
element decides only when nothing visible carries that id, so a link and its
target stay together when the condition later shows it. Hidden elements still
get their own ids put back.
90 changes: 85 additions & 5 deletions packages/blocks-engine/src/composition-planners.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4825,11 +4825,91 @@ describe("a rename record names the nodes it renamed", () => {
).toBe("pricing");
});

it("keeps a governed link pointing at a target that keeps its id", () => {
it("points an unrelated link at the visible renamed node past a gated namesake", () => {
// The namesake is gated, so the page renders only the renamed node. The
// link belongs to a component that renamed nothing; it has to follow the
// node that actually renders the id rather than keep one nothing renders.
const doc = insertedPage();
const renamed = marked([...doc.nodes], "renamed");
const mid = marked([...doc.nodes], "mid");
const withGatedNamesake = applyOps(doc, [
{
kind: "insert",
node: node("namesake", {
cssId: renamed.cssId,
props: { mark: "namesake" },
visibility: {
conditions: [[{ field: "tier", op: "eq", value: "pro" }]],
},
}),
at: { parentId: mid.id, slot: "children", index: 0 },
},
{
kind: "insert",
node: node("stranger", {
origin: { from: "component", id: "def-1" },
attributes: { "aria-describedby": renamed.cssId ?? "" },
props: { mark: "stranger" },
} as Partial<BlockNode>),
at: { parentId: mid.id, slot: "children", index: 0 },
},
]).document;

expect(stored(withGatedNamesake, "wrap", "renamed").cssId).toBe("pricing");
expect(stored(withGatedNamesake, "wrap", "namesake").cssId).toBe(
renamed.cssId
);
expect(
stored(withGatedNamesake, "wrap", "stranger").attributes?.[
"aria-describedby"
]
).toBe("pricing");
});

it("restores a pattern's link inside a gated container beside a visible namesake", () => {
// The container holding the renamed node and its link is gated, and an
// unrelated visible node carries the minted id. The link's own record knows
// where it pointed, so the visible namesake must not decide.
const doc = insertedPage();
const renamed = marked([...doc.nodes], "renamed");
const wrap = marked([...doc.nodes], "wrap");
const mid = marked([...doc.nodes], "mid");
const gatedWithNamesake = applyOps(doc, [
{
kind: "update",
id: mid.id,
patch: {
visibility: {
conditions: [[{ field: "tier", op: "eq", value: "pro" }]],
},
},
},
{
kind: "insert",
node: node("unrelated", {
origin: { from: "component", id: "def-1" },
cssId: renamed.cssId,
props: { mark: "unrelated" },
} as Partial<BlockNode>),
at: { parentId: wrap.id, slot: "children", index: 1 },
},
]).document;

expect(stored(gatedWithNamesake, "wrap", "renamed").cssId).toBe("pricing");
expect(stored(gatedWithNamesake, "wrap", "unrelated").cssId).toBe(
renamed.cssId
);
expect(
stored(gatedWithNamesake, "wrap", "link").attributes?.["aria-describedby"]
).toBe("pricing");
});

it("restores a governed link from its own record past a namesake that keeps its id", () => {
// The listed node stays on the page but outside the selection, so the
// record is live and the link's own record would put the source name back.
// The link's target in the saved forest is an unlisted namesake, which
// keeps the minted id — so the link follows it and keeps the id too.
// record is live and says what the link pointed at. The saved forest holds
// an unlisted namesake that keeps the minted id; the link's own record
// decides, so it goes back to the source name rather than following the
// namesake — as a link saved without its target already does.
const doc = insertedPage();
const renamed = marked([...doc.nodes], "renamed");
const mid = marked([...doc.nodes], "mid");
Expand Down Expand Up @@ -4860,7 +4940,7 @@ describe("a rename record names the nodes it renamed", () => {
expect(marked([...saved.nodes], "namesake").cssId).toBe(renamed.cssId);
expect(
marked([...saved.nodes], "link").attributes?.["aria-describedby"]
).toBe(renamed.cssId);
).toBe("pricing");
});

it("restores nothing when the recorded node id occurs twice", () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/blocks-engine/src/composition-planners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3826,8 +3826,9 @@ function liveEntries(
* namesake can carry one id, and a reference the run's record governs can sit
* beside an unrelated author's reference to the same id — in each, the right
* outcome differs by node. The copier decides which question a node is asked:
* what the id it renders was, or, for an id it references that the copy does not
* settle by its target, what that reference was.
* what the id it renders was, or what an id it references was. A reference takes
* its node's answer where there is one, and follows its target only where there
* is none.
*
* INVERTED from the record, which reads source → copy because that is the
* direction an insert renames in.
Expand Down Expand Up @@ -3904,7 +3905,8 @@ function restoredFor(
* the scope lists the nodes it renamed and this is not one of them.
*
* Only for the node carrying the id itself. A reference is answered by the
* scope alone; whether it follows its target instead is the copier's question.
* scope alone, and that answer decides it; the copier falls back to the target
* only where the scope has none.
*/
function renderedAnswer(
scope: RenameScope | undefined,
Expand Down
155 changes: 151 additions & 4 deletions packages/blocks-engine/src/tree.reid-with-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,9 +880,9 @@ describe('the "restoreEach" DOM id policy', () => {
expect((nodes[1].props as { href: string }).href).toBe("#hero");
});

it("keeps a reference whose one target keeps its id", () => {
// The holder alone would put the source name back, which would store a
// link naming an id its target no longer carries.
it("restores a reference from its holder's answer even when its target keeps its id", () => {
// The holder knows where its reference pointed, so its answer decides; the
// target is followed only for a reference the holder has no answer for.
const { nodes } = reidForestWithMap(
[
node("target", { cssId: "hero-7f3" }),
Expand All @@ -892,7 +892,27 @@ describe('the "restoreEach" DOM id policy', () => {
);

expect(nodes[0].cssId).toBe("hero-7f3");
expect(nodes[1].attributes?.["aria-describedby"]).toBe("hero-7f3");
expect(nodes[1].attributes?.["aria-describedby"]).toBe("hero");
});

it("follows a target placed both open and gated as an open one", () => {
// One node object sits in an open slot and inside a gated container. It
// renders wherever its open placement does, so an unrelated reference
// follows it past a gated namesake — reading it as gated made the two look
// like competing gated targets and left the reference behind.
const sharedTarget = node("shared", { cssId: "shared-1" });
const { nodes } = reidForestWithMap(
[
sharedTarget,
node("wrapper", { visibility: gate }, [sharedTarget]),
node("namesake", { cssId: "shared-1", visibility: gate }),
node("holder", { attributes: { "aria-describedby": "shared-1" } }),
],
{ restoreEach: answers({ shared: { "shared-1": "shared" } }) }
);

expect(nodes[0].cssId).toBe("shared");
expect(nodes[3].attributes?.["aria-describedby"]).toBe("shared");
});

it("lets each holder decide a reference nothing in the forest renders", () => {
Expand Down Expand Up @@ -987,6 +1007,133 @@ describe('the "restoreEach" DOM id policy', () => {

expect([...domIds.entries()]).toEqual([["hero-7f3", "hero"]]);
});
/** A condition gate the renderer prunes for a viewer who does not match. */
const gate = {
conditions: [[{ field: "tier", op: "eq", value: "pro" }]],
} as BlockNode["visibility"];

it("follows the visible target past a gated namesake", () => {
// The gated namesake renders nothing, so the reference resolves to the
// visible node on the page. Counting both as targets made the id look
// contested and left the reference on an id nothing renders.
const { nodes } = reidForestWithMap(
[
node("visible", { cssId: "shared-1" }),
node("hidden", { cssId: "shared-1", visibility: gate }),
node("holder", { attributes: { "aria-describedby": "shared-1" } }),
],
{ restoreEach: answers({ visible: { "shared-1": "shared" } }) }
);

expect(nodes[0].cssId).toBe("shared");
expect(nodes[1].cssId).toBe("shared-1");
expect(nodes[2].attributes?.["aria-describedby"]).toBe("shared");
});

it("follows a gated target when nothing visible carries the id", () => {
// Excluding gated nodes outright would hand this reference to its holder,
// which answers nothing — storing a link to an id its target no longer
// carries once the gate opens.
const { nodes } = reidForestWithMap(
[
node("hidden", { cssId: "shared-1", visibility: gate }),
node("holder", { attributes: { "aria-describedby": "shared-1" } }),
],
{ restoreEach: answers({ hidden: { "shared-1": "shared" } }) }
);

expect(nodes[0].cssId).toBe("shared");
expect(nodes[1].attributes?.["aria-describedby"]).toBe("shared");
});

it("treats a node inside a gated subtree as gated", () => {
// Gating is inherited: the renderer prunes the whole subtree, so a
// namesake under a gated parent renders nothing either.
const { nodes } = reidForestWithMap(
[
node("visible", { cssId: "shared-1" }),
node("wrapper", { visibility: gate }, [
node("nested", { cssId: "shared-1" }),
]),
node("holder", { attributes: { "aria-describedby": "shared-1" } }),
],
{ restoreEach: answers({ visible: { "shared-1": "shared" } }) }
);

expect(nodes[0].cssId).toBe("shared");
expect(nodes[2].attributes?.["aria-describedby"]).toBe("shared");
});
it("restores a link inside a gated subtree from its own answer past a visible namesake", () => {
// The link's holder has an answer, so it decides: the link must not be
// handed to an unrelated visible namesake just because that one always
// renders.
const { nodes } = reidForestWithMap(
[
node("unrelated", { cssId: "shared-1" }),
node("wrapper", { visibility: gate }, [
node("target", { cssId: "shared-1" }),
node("link", { attributes: { "aria-describedby": "shared-1" } }),
]),
],
{
restoreEach: answers({
target: { "shared-1": "shared" },
link: { "shared-1": "shared" },
}),
}
);
const inside = nodes[1].slots?.children ?? [];

expect(nodes[0].cssId).toBe("shared-1");
expect(inside[0]?.cssId).toBe("shared");
expect(inside[1]?.attributes?.["aria-describedby"]).toBe("shared");
});

it("restores a gated link from its own answer beside a namesake that keeps its id", () => {
// The only node rendering the id inside the gate keeps it, and nothing
// visible carries it. The link's holder knows where it pointed, so its
// answer decides rather than whichever node happens to share the id.
const { nodes } = reidForestWithMap(
[
node("wrapper", { visibility: gate }, [
node("namesake", { cssId: "shared-1" }),
node("link", { attributes: { "aria-describedby": "shared-1" } }),
]),
],
{ restoreEach: answers({ link: { "shared-1": "shared" } }) }
);
const inside = nodes[0].slots?.children ?? [];

expect(inside[0]?.cssId).toBe("shared-1");
expect(inside[1]?.attributes?.["aria-describedby"]).toBe("shared");
});

it("restores a link under nested gates from its own answer past a visible namesake", () => {
// However many gates sit above the link, its holder's answer decides; a
// visible namesake that always renders does not.
const { nodes } = reidForestWithMap(
[
node("unrelated", { cssId: "shared-1" }),
node("outer", { visibility: gate }, [
node("target", { cssId: "shared-1" }),
node("inner", { visibility: gate }, [
node("link", { attributes: { "aria-describedby": "shared-1" } }),
]),
]),
],
{
restoreEach: answers({
target: { "shared-1": "shared" },
link: { "shared-1": "shared" },
}),
}
);
const outer = nodes[1].slots?.children ?? [];
const inner = outer[1]?.slots?.children ?? [];

expect(outer[0]?.cssId).toBe("shared");
expect(inner[0]?.attributes?.["aria-describedby"]).toBe("shared");
});
});

describe("only the id a node RENDERS may be reminted", () => {
Expand Down
Loading
Loading