Skip to content

Props reactivity broken with Svelte 5 - controlled component pattern not working #56

@mack-erel

Description

@mack-erel

Description

When using sveltify() to wrap React components in Svelte 5, prop changes are not reflected in the React component. This breaks the controlled component pattern commonly used in React.

Reproduction

<script>
  import { sveltify } from "svelte-preprocess-react";
  import { Accordion } from "some-react-library";

  const react = sveltify({ Accordion });

  let isOpened = $state(false);
</script>

<button onclick={() => isOpened = !isOpened}>
  Toggle: {isOpened}
</button>

<react.Accordion 
  isOpened={isOpened} 
  onOpen={() => isOpened = true} 
  onClose={() => isOpened = false}
>
  Content
</react.Accordion>

Expected behavior

When clicking the button, isOpened changes and the React Accordion component should open/close accordingly.

Actual behavior

The button text updates (showing state is changing), but the React component does not update.

Environment

  • svelte-preprocess-react: 3.0.0-beta.0
  • Svelte: 5.45.6
  • React: 19.2.3

Root Cause Analysis

I investigated and found two issues in SveltifiedCSR.svelte:

1. Reactivity loss from rest spread on $props()

// Current code
const { react$component, react$children, children, ...props } = $props();

In Svelte 5, using rest spread (...props) on $props() creates a plain object, breaking fine-grained reactivity tracking. The $effect does not re-run when individual props change.

2. React batching conflict with Svelte's $effect

Even after fixing reactivity, React's internal batching mechanism skips re-renders when app.render() is called from within Svelte's $effect (which runs in a microtask).

Suggested Fix

// 1. Keep $props() as whole object
const allProps = $props<Record<string, any>>();

$effect(() => {
  if (!target) return;
  
  // 2. Force read all properties for dependency tracking
  const _trigger = JSON.stringify(allProps);
  const { react$component, react$children, children: _children, ...props } = allProps;
  
  // 3. Use flushSync to force synchronous React rendering
  flushSync(() => {
    app.render(/* ... */);
  });
});

// 4. Update template references
{#if allProps.children}
  {@render allProps.children()}
{/if}

I tested this fix locally and controlled component pattern now works correctly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions