Skip to content

fix(layout): resolve LayoutGroup id at init and stop swallowing context errors - #77

Open
JonathonRP wants to merge 1 commit into
mainfrom
jonathonrp-fix-layoutgroup-id-prefix-loss
Open

fix(layout): resolve LayoutGroup id at init and stop swallowing context errors#77
JonathonRP wants to merge 1 commit into
mainfrom
jonathonrp-fix-layoutgroup-id-prefix-loss

Conversation

@JonathonRP

Copy link
Copy Markdown
Owner

The problem

packages/motion-start/src/motion/index.svelte.ts assembled configAndProps like this:

const configAndProps = $derived.by(() => {
	const propsState = $state({
		...motionConfig,
		...motionProps,
		layoutId: useLayoutId(() => motionProps),
	});
	return propsState;
});

useLayoutId calls useLayoutGroupContext(), which reaches into Svelte's context system. getContext/hasContext are component-initialisation APIs — they throw lifecycle_outside_component whenever component_context is null. Putting one inside a value that recomputes on every props change means the context lookup is re-entered long after initialisation, from whatever call site happens to pull on the derived.

That was made invisible by a blanket catch in packages/motion-start/src/context/LayoutGroupContext.svelte.ts:

function useLayoutGroupContext() {
	try {
		return getLayoutGroupContext();
	} catch {
		return null;
	}
}

The catch existed for a legitimate reason — motion elements are routinely rendered with no LayoutGroup ancestor, and createContext's getter throws missing_context in that case. But it caught everything, so a lifecycle violation was indistinguishable from "no provider" and degraded to layoutGroupId === undefined. The observable symptom would be layoutId losing its LayoutGroup prefix after a prop change, which silently breaks shared-layout animations between grouped elements.

Honest scoping note

I wrote the regression test first and it passed against the unfixed code. Digging into svelte@5.56.8, update_reaction restores the reaction's captured component context before recomputing (set_component_context(reaction.ctx) in internal/client/runtime.js), and $derived signals capture ctx: component_context at creation (internal/client/reactivity/deriveds.js). Because renderMotionComponent runs inside MotionScope's initialisation, the derived captures a non-null ctx and getContext still resolves on recompute. So on this Svelte version the prefix does not actually drop today.

What remains real, and is what this PR fixes:

  • The call is still an initialisation-only API invoked from a recomputing derived. It only works by virtue of a Svelte implementation detail about ctx restoration, and it breaks the moment the value is pulled from outside a reaction (a tick().then(), a rAF callback, a microtask render) or the derived is created without a component context.
  • The blanket catch genuinely does erase that failure mode. That is the part that made the hazard undiagnosable, and it is removed.
  • The redundant per-recompute context lookup is now done once.

I've left the reproduction test in as a regression guard rather than deleting it, and added a direct test that the context helper throws outside initialisation.

The fix

  • Resolve once at init. const layoutGroupId = useLayoutGroupContext()?.id; now sits in the component body, outside any $derived. The derived reads the already-resolved value.
  • useLayoutIdgetLayoutId. The old use* hook was the footgun; it's replaced by a pure getLayoutId(props, layoutGroupId) that is safe to call from a derived. Nothing else in the repo referenced useLayoutId.
  • No more blanket catch. useLayoutGroupContext now uses an explicit hasContext check for the documented "no provider" case and lets everything else throw. I checked all four call sites first:
    • LayoutGroup.svelteuseLayoutGroupContext() || { id: oldId }, legitimately runs with no provider at the root of a group. Covered by hasContext.
    • MeasureLayout.svelte?? { forceRender: () => {} }, same.
    • motion/index.svelte.ts — now init-only.
    • LayoutGroupConsumer.svelte (test fixture) — now has explicit coverage for the no-provider case.
  • Dead code removed. createLayoutGroupContext and LayoutGroupContextType (the rune-store implementation) had no importers anywhere in the repo — only doc/plan files mention them. Deleted, along with their re-export from the src/context barrel. The barrel isn't in package.json#exports and isn't imported anywhere, so this isn't a public API break.

I kept the .svelte.ts extension on LayoutGroupContext.svelte.ts even though it no longer contains runes, because src/index.ts re-exports the LayoutGroupContext type from that path.

Tests

  • layout-group-motion.svelte.spec.ts (new): mounts motion.div with layoutId="box" inside <LayoutGroup id="group">, changes a style prop, and asserts both visualElement.getProps().layoutId and projection.options.layoutId are still group-box. It asserts the props recompute actually happened (style.width === '200px') so it can't pass vacuously.
  • layout-group.svelte.spec.ts: added coverage that useLayoutGroupContext() returns null with no provider and throws when called outside component initialisation.

Verification

  • bun run test:run — 620 passed, 1 skipped. The single failure (package-imports.spec.ts "fully specifies relative JavaScript imports") is a 5s-timeout flake on this machine and reproduces identically on a clean git stashed tree; it's unrelated to this change.
  • bun run check (svelte-check) — 0 errors, 0 warnings.
  • biome lint on the touched files — no new diagnostics (remaining ones are pre-existing noExplicitAny/CRLF noise shared with main).

Follow-up, not done here

use-visual-element.svelte.ts has the same shape — $derived(useSwitchLayoutGroupContext()) — and SwitchLayoutGroupContext.ts / DeprecatedLayoutGroupContext.ts both still use blanket try/catch. Left alone deliberately to keep this PR scoped; worth folding into the planned context refactor.

…xt errors

`motion` components read their enclosing `LayoutGroup` id from inside the
`$derived` that assembles `configAndProps`. `getContext` is an initialisation-only
API, so keeping it inside a value that recomputes on every props change is a
latent hazard: any recompute that lands outside a reaction throws, and the blanket
`try`/`catch` in `useLayoutGroupContext` turned that throw into `null`, silently
dropping the group prefix from `layoutId` and breaking shared-layout animations.

- Resolve the layout group id once, during component initialisation, as a plain
  `const`; the derived now reads the already-resolved value.
- Replace the `useLayoutId` hook with the pure `getLayoutId` helper.
- Detect a missing `LayoutGroup` provider with `hasContext` instead of catching
  everything, so genuine misuse throws loudly.
- Delete the unused `createLayoutGroupContext` rune store and `LayoutGroupContextType`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@changeset-bot

changeset-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 57478b4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
motion-start Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@JonathonRP, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 05afc2b7-3873-4324-8d26-01ab376395db

📥 Commits

Reviewing files that changed from the base of the PR and between 0adcef2 and 57478b4.

📒 Files selected for processing (7)
  • .changeset/slimy-hoops-repair.md
  • packages/motion-start/src/components/LayoutGroup/__tests__/LayoutGroupMotionFixture.svelte
  • packages/motion-start/src/components/LayoutGroup/__tests__/layout-group-motion.svelte.spec.ts
  • packages/motion-start/src/components/LayoutGroup/__tests__/layout-group.svelte.spec.ts
  • packages/motion-start/src/context/LayoutGroupContext.svelte.ts
  • packages/motion-start/src/context/index.svelte.ts
  • packages/motion-start/src/motion/index.svelte.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/motion-start@77

commit: 57478b4

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.

1 participant