Skip to content

fix(content-system): scope the layout write memo to the write that opened it - #20675

Open
Martin Krzykawski (MartinKrzykawski) wants to merge 2 commits into
feat/experience-studiofrom
fix/content-system-layout-write-memo-scope
Open

Martin Krzykawski (MartinKrzykawski) wants to merge 2 commits into
feat/experience-studiofrom
fix/content-system-layout-write-memo-scope

Conversation

@MartinKrzykawski

@MartinKrzykawski Martin Krzykawski (MartinKrzykawski) commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

1. Why is this change necessary?

Layout/LayoutWriteContext carries the already-decoded layout tree from the field serializer to the write gate. It rides the caller-owned Context, which outlives any single write, and pairs commands to trees positionally: a key holds every tree remembered under it, and consume() hands out the oldest.

Nothing tied an entry to the write that produced it, and nothing cleared one when a write failed between normalize() and PreWriteValidationEvent. So a later write to the same row appended its tree behind the leftover and the gate consumed the leftover instead.

The consequence is not a memory note — it is a gating hole. The gate judged a tree the write did not carry, so a write carrying an unresolvable layout was accepted because the leftover happened to be resolvable, storing a row that served-implies-resolvable says cannot exist.

Item 8 of #19954.

2. What does this change do, exactly?

The memo now belongs to its write. WriteContext is minted once per repository call and both sides already reach it — the serializer through WriteParameterBag::getContext(), the gate through PreWriteValidationEvent::getWriteContext() — so that instance identifies the write.

  • LayoutWriteContext takes its owning WriteContext and exposes ownedBy() (identity comparison).
  • StoredElementListFieldSerializer::memoize() replaces a memo owned by an earlier write instead of appending to it.
  • ContentLayoutWriteValidator treats a memo owned by another write as absent.

Thirteen lines of logic across three files; fewer is not possible, because the writer and the reader have to agree about the same object. The accumulation ceiling drops from the Context's whole lifetime to one write's rows.

This does not make the memo empty immediately after a failed write — the entries are dropped when the next write replaces the memo. They are unreachable from that moment on, which is what the gating guarantee needs; the class docblock states it that way rather than claiming more.

3. Describe each step to reproduce the issue or behaviour.

On feat/experience-studio, reusing one Context for both writes:

  1. create() two content_layout rows in one call, where the second row carries 'layout' => 'not-a-tree'. Row one is memoized, row two throws inside normalize(), the write aborts and PreWriteValidationEvent never fires — row one's tree stays on the Context.
  2. upsert() row one again on the same Context, this time with an element whose type is unresolvable against the layout's root source.
  3. Before: the write is accepted and the unresolvable layout is stored, because the gate consumed the resolvable tree from step 1. After: the write is rejected on its own tree.

ContentLayoutWriteMemoLifetimeTest::testWriteAfterAFailedWriteIsJudgedAgainstItsOwnTree drives exactly that.

4. Please link to the relevant issues (if any).

relates #19954

5. Checklist

  • I have written tests and verified that they fail without my change
  • I have updated developer-facing release notes if this change is relevant for external developers: not applicable — the content system is unreleased and this PR targets feat/experience-studio; LayoutWriteContext is @internal
  • I have written or adjusted the documentation and agent skills according to my changes
  • This change has comments for package types, values, functions, and non-obvious lines of code
  • I have read the contribution requirements and fulfilled them

Falsification detail: both ownership checks are covered, and each by the test that actually reaches it.

  • Removing the serializer check makes ContentLayoutWriteMemoLifetimeTest::testWriteAfterAFailedWriteIsJudgedAgainstItsOwnTree fail — the second write is accepted and the unresolvable layout is stored.
  • Removing the gate check makes ContentLayoutWriteValidatorTest::testForeignOwnedMemoIsNotConsumed and testForeignOwnedMemoIsNotDrainedUnderTheSkipState fail.

The second pair was added after review: the integration test alone left the gate's check green when removed, because the serializer had already replaced the foreign memo by the time the gate ran.

…ened it

LayoutWriteContext rides the caller-owned Context, which outlives any single
write, and pairs commands to trees positionally. Nothing tied an entry to the
write that produced it and nothing cleared one when a write failed between
normalize() and the validation event, so a later write to the same row appended
its tree behind the stale one and consume() handed out the stale one first.

The consequence is not cosmetic: the gate judged a tree the write did not
carry. A write carrying an unresolvable layout was accepted because the leftover
tree was resolvable, storing a row the served-implies-resolvable invariant says
cannot exist.

The memo now belongs to its write. WriteContext is minted per repository call
and both sides can reach it — the serializer through WriteParameterBag, the
validator through PreWriteValidationEvent::getWriteContext() — so that instance
identifies the write. A memo from an earlier write is replaced by the serializer
and counts as absent to the validator.

ContentLayoutWriteMemoLifetimeTest drives a write that dies during normalize,
then a second write of an unresolvable tree on the same Context. Verified by
falsification: with both ownership checks removed the second write is accepted
and the unresolvable layout is stored.
@github-actions

Copy link
Copy Markdown
Contributor
Warnings
⚠️ The Pull Request doesn't contain any release info, if your changes are relevant for external developers please add an entry to the release info file, including the consequences of the change and how it affects external developers. For detailed infos please refer to the [release documentation guide](https://github.com/shopware/shopware/blob/trunk/delivery-process/documenting-a-release.md).

@explore-openapi

explore-openapi Bot commented Sep 22, 2026

Copy link
Copy Markdown

OpenAPI Snapshot

⚠️ shopware/store-api — base trunk — 22 changes (19 additions · 3 modifications)

Diff: https://explore-openapi.dev/diff#...

📋 Additional Information

ℹ️ This pull request targets feat/experience-studio, which this project has no snapshot of. The diff is taken against trunk instead, so whatever feat/experience-studio already changed relative to trunk is counted here as this pull request's own change.

This pull request's own changes are shown applied on top of the current trunk, so schema changes merged into trunk in the meantime are not counted as changes here.

⚠️ trunk changed a node this pull request also changed: #/paths/~1account~1register/post. The diff shows this branch's version.

Run the action on feat/experience-studio to compare this pull request against the branch it targets.

⚠️ shopware/admin-api — base trunk — 180 changes (164 additions · 6 removals · 10 modifications)

Diff: Diff link is too large for a reliable GitHub comment link. View action log with full link

📋 Additional Information

ℹ️ This pull request targets feat/experience-studio, which this project has no snapshot of. The diff is taken against trunk instead, so whatever feat/experience-studio already changed relative to trunk is counted here as this pull request's own change.

This pull request's own changes are shown applied on top of the current trunk, so schema changes merged into trunk in the meantime are not counted as changes here.

Run the action on feat/experience-studio to compare this pull request against the branch it targets.

@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (feat/experience-studio@fcfee6c). Learn more about missing BASE report.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@                    Coverage Diff                    @@
##             feat/experience-studio   #20675   +/-   ##
=========================================================
  Coverage                          ?   69.66%           
=========================================================
  Files                             ?     6085           
  Lines                             ?   210639           
  Branches                          ?     9652           
=========================================================
  Hits                              ?   146737           
  Misses                            ?    61084           
  Partials                          ?     2818           
Flag Coverage Δ
phpunit-migration 94.93% <ø> (?)
phpunit-unit 68.41% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot added the needs-triage A newly created issue or PR that needs team assignment and/or prioritization. label Sep 22, 2026
@MartinKrzykawski Martin Krzykawski (MartinKrzykawski) added domain/discovery Responsible for enabling the shopper to discover precisely the products that they want and removed needs-triage A newly created issue or PR that needs team assignment and/or prioritization. labels Sep 22, 2026
The integration test only covers the serializer side: once the serializer
replaces a foreign memo, the gate always sees its own, so removing the gate's
ownedBy() left every test green. Two unit tests close that — a layout command
gated against an earlier write's memo now fails with layoutWriteMemoMissing,
and the skip path leaves that memo untouched instead of draining it. Both fail
with the check removed.

Also folded in while here: the memo helper becomes an instance method, so the
file no longer mixes self:: and $this-> on adjacent lines; the createDataStack()
paragraph names the failure the ownership check actually produces there (a loud
layoutWriteMemoMissing from the cloned WriteContext, not a silent mispairing);
and the serializer's inline comment gives way to a {@see} on the method
docblock, where it does not restate LayoutWriteContext's own prose.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/discovery Responsible for enabling the shopper to discover precisely the products that they want

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant