Skip to content

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

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.

Activity

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

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