Skip to content

fix: allow custom elements to opt into live-preview morphing - #125

Closed
thelazylamaGit wants to merge 4 commits into
relative23:mainfrom
thelazylamaGit:opt-in-data-payload-morph-custom-element-attribute
Closed

thelazylamaGit wants to merge 4 commits into
relative23:mainfrom
thelazylamaGit:opt-in-data-payload-morph-custom-element-attribute

Conversation

@thelazylamaGit

Copy link
Copy Markdown
Contributor

Summary

A pattern I like to do quite a bit in Astro is to create Web Components / Custom-Elements as Astro components, this even fits in quite nicely with live-preview fragment updates as it automatically handles components being added and removed from the dom.

However, currently live-preview updates skip custom elements because the morphing logic rejects tag names containing a hyphen.

This PR adds an opt-in data-payload-morph attribute so custom elements can receive live-preview updates. Custom elements without the attribute retain the existing behaviour.

There's a lot of files here but the core fix is just a small change to isMorphBoundary in morph.ts

if (tag.includes('-') && !element.hasAttribute('data-payload-morph')) return true;

Linked issue

No linked issue. This fixes live-preview updates for custom elements that explicitly opt into morphing.

Checklist

  • npm run check passes
  • Patch changeset added
  • Tests cover opted-in custom elements and existing behavior without the attribute
  • Docs explain how to use data-payload-morph
  • Runtime artifacts regenerated and npm run check:generated passes

@thelazylamaGit thelazylamaGit changed the title Opt in data payload morph custom element attribute fix: allow custom elements to opt into live-preview morphing Oct 8, 2026
@relative23

Copy link
Copy Markdown
Owner

Thanks for this, and for the E2E test. An opt-in attribute fits the problem well: custom elements are skipped on purpose (ADR 0008 §4), and a page author needs a way to say "this one is mine to morph".

Plan: this ships with 2.1.0, not as a patch release. It adds a new template-level contract, so the minor in your changeset is the right call (the title and the checklist say fix/patch, we'll align those when we port it). main is being moved by the 2.1 integration right now, so I'm holding the merge until that has landed rather than make you rebase twice.

When we port it, your commits and authorship stay, and our changes go on top as separate commits: in 2.1 the hyphen rule lives in islands.ts, ADR 0008 §9 has a different heading there so the new section becomes §10, and the budgets, trusted core and the sanitizer mutation score get re-measured.

One docs point we'll add to the README: we'll document that the morph also replaces attributes and children the component set itself.

CI: the E2E overhead check fails because the runtime grew (AUTHORIZED_NEXT_DELIVERY.overheadBytes in tests/fixtures/delivery-budgets.ts); we'll move it when we port. The two audit failures are not from your change (the PR touches no dependency files). Nothing for you to do for now.

relative23 pushed a commit that referenced this pull request Oct 16, 2026
…payload-morph

The keyed morph treats every custom element as a boundary it never enters, so
an Astro component written as a custom element got no live-preview update of
its host attributes or its server-rendered light DOM, and a host that wanted
one had no way to say so.

A host with `data-payload-morph` on both the current and the newly rendered
element is morphed: it keeps its identity, its attributes and light DOM follow
the server, and its shadow root stays untouched. The value is ignored. A host
marked on one side only is replaced as before, and islands, `contenteditable`
and `data-payload-owned` descendants stay boundaries inside it. The sanitizer
lets the attribute through in template markup only; CMS content keeps it
stripped. The rule sits in `isMorphBoundary`, which the structural applier, the
fragment strategy and the route strategy share through `MorphOptions.boundary`;
ADR 0008 gets section 10, and the README names the cost: attributes and
children an opted-in host set itself are overwritten.

Adapted from #125, which changed `isMorphBoundary` in morph.ts, where the rule
lived before 2.1; it now lives in islands.ts. Its unit cases pass; this adds the one-sided case, the fragment and route
strategies, the island/owned/contenteditable guards and an end-to-end spec that
follows a probe element through a fragment render and a route refresh in
Chromium, Firefox and WebKit. Seven of the new unit tests fail on the unchanged
source.

Budgets move by paired builds at one epoch, each row by its measured
difference with its cushion kept. The tree-shaking budgets and their log move
into their own module because check-tree-shaking.ts stood at the 600-line
limit, and the trusted core grows 2 063 -> 2 064 with a dated reason. The
Next.js authorized-delivery overhead moves 14 133 -> 14 309: 131 B of that come
from the integrated tree, where the row was already red before this change, and
45 B from the opt-in. In the core mutation scope sanitizer.ts reports 430
mutants, 429 killed and one timed out.

Refs #125
relative23 pushed a commit that referenced this pull request Oct 16, 2026
…payload-morph

The keyed morph treats every custom element as a boundary it never enters, so
an Astro component written as a custom element got no live-preview update of
its host attributes or its server-rendered light DOM, and a host that wanted
one had no way to say so.

A host with `data-payload-morph` on both the current and the newly rendered
element is morphed: it keeps its identity, its attributes and light DOM follow
the server, and its shadow root stays untouched. The value is ignored. A host
marked on one side only is replaced as before, and islands, `contenteditable`
and `data-payload-owned` descendants stay boundaries inside it. The sanitizer
lets the attribute through in template markup only; CMS content keeps it
stripped. The rule sits in `isMorphBoundary`, which the structural applier, the
fragment strategy and the route strategy share through `MorphOptions.boundary`;
ADR 0008 gets section 10, and the README names the cost: attributes and
children an opted-in host set itself are overwritten.

Adapted from #125, which changed `isMorphBoundary` in morph.ts, where the rule
lived before 2.1; it now lives in islands.ts. Its unit cases pass; this adds the one-sided case, the fragment and route
strategies, the island/owned/contenteditable guards and an end-to-end spec that
follows a probe element through a fragment render and a route refresh in
Chromium, Firefox and WebKit. Seven of the new unit tests fail on the unchanged
source.

Budgets move by paired builds at one epoch, each row by its measured
difference with its cushion kept. The tree-shaking budgets and their log move
into their own module because check-tree-shaking.ts stood at the 600-line
limit, and the trusted core grows 2 063 -> 2 064 with a dated reason. The
Next.js authorized-delivery overhead moves 14 133 -> 14 309: 131 B of that come
from the integrated tree, where the row was already red before this change, and
45 B from the opt-in. In the core mutation scope sanitizer.ts reports 430
mutants, 429 killed and one timed out.

Refs #125
relative23 pushed a commit that referenced this pull request Oct 16, 2026
…payload-morph

The keyed morph treats every custom element as a boundary it never enters, so
an Astro component written as a custom element got no live-preview update of
its host attributes or its server-rendered light DOM, and a host that wanted
one had no way to say so.

A host with `data-payload-morph` on both the current and the newly rendered
element is morphed: it keeps its identity, its attributes and light DOM follow
the server, and its shadow root stays untouched. The value is ignored. A host
marked on one side only is replaced as before, and islands, `contenteditable`
and `data-payload-owned` descendants stay boundaries inside it. The sanitizer
lets the attribute through in template markup only; CMS content keeps it
stripped. The rule sits in `isMorphBoundary`, which the structural applier, the
fragment strategy and the route strategy share through `MorphOptions.boundary`;
ADR 0008 gets section 10, and the README names the cost: attributes and
children an opted-in host set itself are overwritten.

Adapted from #125, which changed `isMorphBoundary` in morph.ts, where the rule
lived before 2.1; it now lives in islands.ts. Its unit cases pass; this adds the one-sided case, the fragment and route
strategies, the island/owned/contenteditable guards and an end-to-end spec that
follows a probe element through a fragment render and a route refresh in
Chromium, Firefox and WebKit. Seven of the new unit tests fail on the unchanged
source.

Budgets move by paired builds at one epoch, each row by its measured
difference with its cushion kept. The tree-shaking budgets and their log move
into their own module because check-tree-shaking.ts stood at the 600-line
limit, and the trusted core grows 2 063 -> 2 064 with a dated reason. The
Next.js authorized-delivery overhead moves 14 133 -> 14 309: 131 B of that come
from the integrated tree, where the row was already red before this change, and
45 B from the opt-in. In the core mutation scope sanitizer.ts reports 430
mutants, 429 killed and one timed out.

Refs #125
@relative23

Copy link
Copy Markdown
Owner

Thanks for this, @thelazylamaGit. data-payload-morph is on main through #127 and ships with 2.1, with you as the author of the commit (07f2d9c).

Two things differ from the PR. In 2.1 the ownership rule lives in isMorphBoundary in src/core/islands.ts, shared by the structural applier, the fragment strategy and the route strategy, so the attribute is read in one place. The sanitizer accepts it in template markup only; CMS content keeps it stripped, so an editor cannot opt a host in.

Added on top: a host marked on one side only is replaced as before, islands, contenteditable and data-payload-owned descendants stay boundaries inside an opted-in host, and an end-to-end spec follows a probe element through a fragment render and a route refresh in Chromium, Firefox and WebKit.

Closing because the change landed through that PR rather than this one.

@relative23 relative23 closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants