Skip to content

[BUG] Fix KotlinReflectionInternalError for value-class @PreviewParameter previews - #142

Merged
sergio-sastre merged 4 commits into
sergio-sastre:masterfrom
nshurtz:bugfix/value-class-preview-parameter
Aug 17, 2026
Merged

[BUG] Fix KotlinReflectionInternalError for value-class @PreviewParameter previews#142
sergio-sastre merged 4 commits into
sergio-sastre:masterfrom
nshurtz:bugfix/value-class-preview-parameter

Conversation

@nshurtz

@nshurtz nshurtz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #141

Problem

A @Preview whose @PreviewParameter provider yields a Kotlin value class crashed both getPreviews() and ComposablePreview.invoke() with KotlinReflectionInternalError: Inconsistent number of parameters … arity != expectedArgsSize. The scanner resolved the preview method via kotlin-reflect, whose ValueClassAwareCaller asserts the Kotlin-descriptor arity equals the JVM arity — which never holds for @Composable functions, because the Compose compiler appends synthetic Composer/changed/default-mask parameters absent from the descriptor. Only value-class previews are routed through that caller, so only they crashed.

Fix

Switch both paths off kotlin-reflect onto androidx.compose.runtime.reflect.ComposableMethod (java.lang.reflect-based, Compose-ABI aware):

  • ProvideComposablePreview.methodParametersType — derive the real parameter count via asComposableMethod() instead of kotlinFunction.
  • ComposablePreviewInvocationHandler — invoke via ComposableMethod.invoke, unboxing value-class arguments; preserves the PreviewWrapper path, bound-instance invocation, and the "@PreviewParameter must be first" guard.

Tests

  • New PreviewParameterProvider<Dp> fixture in its own top-level package (so the broad invoke suites don't pick it up).
  • Enumeration/toString test asserting the value-class preview is scanned without crashing.
  • Paparazzi render test exercising the invocation path; baselines recorded on Linux/JDK 17 to match CI.

Notes

The scanned id keeps the raw JVM method name, which for a value-class parameter is mangled (<name>-<hash>); this is intentional so previews whose value-class params share an underlying JVM type don't collide (see the comment in ComposablePreviewToStringTest).


This contribution (code and description) was prepared with AI assistance (Claude Code) and reviewed by me before submission.

Summary by CodeRabbit

  • New Features

    • Added support for Compose previews that use value-class parameters supplied by preview parameter providers.
    • Added compatibility for AndroidX and JetBrains Compose preview parameter annotations.
    • Improved handling of preview parameters, receivers, and default values during preview rendering.
  • Bug Fixes

    • Prevented crashes when scanning and rendering previews with value-class parameters.
  • Tests

    • Added coverage for preview discovery, stable preview identifiers, and Paparazzi and Roborazzi screenshot rendering.

A @Preview whose @PreviewParameter provider yields a Kotlin value class (e.g.
PreviewParameterProvider<Dp>) crashed both getPreviews() and invocation. The
scanner reflected/invoked the composable via kotlin-reflect, whose
ValueClassAwareCaller asserts the Kotlin-descriptor arity equals the JVM method
arity. That fails for @composable functions because the Compose compiler appends
synthetic Composer/changed/default-mask parameters that aren't in the descriptor
(3 != 1). Non-value-class previews skip that caller, which is why only value-class
previews crashed.

Switch both paths to androidx.compose.runtime.reflect.ComposableMethod
(java.lang.reflect, Compose-ABI aware), which accounts for the synthetic params:

- ProvideComposablePreview.methodParametersTypeAsString: derive the real
  parameter count via asComposableMethod() instead of kotlinFunction.
- ComposablePreviewInvocationHandler: invoke via ComposableMethod.invoke,
  unboxing value-class arguments; preserve the PreviewWrapper path, bound-instance
  invocation, and the "@PreviewParameter must be first" guard.

Add a PreviewParameterProvider<Dp> fixture (in its own top-level package) with an
enumeration/toString test and a Paparazzi render test; the suite previously had no
value-class coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

Walkthrough

The scanner now uses Compose-aware reflection for preview parameter counting and composable invocation. It supports boxed Kotlin value-class parameters, validates parameter placement, recognizes both Compose PreviewParameter annotations, and adds scanning and rendering tests.

Changes

Value-class preview parameter support

Layer / File(s) Summary
Compose-aware invocation and parameter resolution
core/src/main/java/sergio/sastre/composable/preview/scanner/core/preview/ComposablePreviewInvocationHandler.kt, core/src/main/java/sergio/sastre/composable/preview/scanner/core/preview/ProvideComposablePreview.kt
Uses asComposableMethod() for invocation and parameter counting. Handles receivers and compiler arguments, validates preview parameter placement, recognizes both annotation names, and unboxes value-class arguments.
Value-class regression coverage
tests/src/main/java/valueclass/previewparameters/android/Composables.kt, tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/api/main/screenshotid/ComposablePreviewToStringTest.kt, tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/paparazzi/runtime/PaparazziValueClassPreviewParameterInvokeTest.kt, tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/roborazzi/runtime/RoborazziValueClassPreviewParameterInvokeTest.kt
Adds Dp preview fixtures and verifies preview discovery, stable IDs, invocation, Paparazzi rendering, and Robolectric rendering.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PreviewParameterProvider
  participant ComposablePreviewInvocationHandler
  participant ComposableMethod
  participant PreviewComposable
  PreviewParameterProvider->>ComposablePreviewInvocationHandler: provide Dp value
  ComposablePreviewInvocationHandler->>ComposableMethod: invokeComposable with Composer, changed, and value
  ComposableMethod->>PreviewComposable: supply synthetic arguments and invoke
  PreviewComposable-->>ComposableMethod: render preview content
Loading

Poem

A rabbit boxed a Dp with care,

Then sent it through the Compose air.
Hidden arguments joined the flight,
While screenshots checked the result right.
Thirty-two and sixty-four hopped there.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Kotlin reflection crash and the value-class PreviewParameter fix addressed by the changes.
Linked Issues check ✅ Passed The PR replaces kotlin-reflect paths with Compose-aware invocation and adds enumeration, Paparazzi, and Roborazzi coverage for value-class parameters.
Out of Scope Changes check ✅ Passed The implementation and tests directly support issue #141 and contain no unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/src/main/java/sergio/sastre/composable/preview/scanner/core/preview/ComposablePreviewInvocationHandler.kt`:
- Around line 72-88: Update the invocation flow around
ComposablePreviewInvocationHandler and ComposableMethod.invoke so a
provider-emitted null for a nullable first `@PreviewParameter` is passed
explicitly rather than interpreted as a request to use the Kotlin default.
Preserve existing coercion and no-parameter behavior, and add rendering coverage
for a nullable preview parameter with a default receiving explicit null.

In
`@core/src/main/java/sergio/sastre/composable/preview/scanner/core/preview/ProvideComposablePreview.kt`:
- Around line 69-74: Update the fallback calculation in the preview parameter
extraction flow around previewMethod and realParametersCount to determine the
real parameter count from the index of the last Composer parameter, rather than
subtracting two from parameterTypes.size. Preserve the asComposableMethod()
count when available and use the resulting count for genericParameterTypes.take
so synthetic mask parameters are excluded.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d7a6de7f-923a-407c-bee1-8a5ffb9dd460

📥 Commits

Reviewing files that changed from the base of the PR and between 83e9280 and 9d43037.

⛔ Files ignored due to path filters (2)
  • tests/src/test/snapshots/images/Paparazzi_Preview_Test_valueclass.previewparameters.android.composableskt.valueclasspreviewparameterpreview-8feqmps_float_0.png is excluded by !**/*.png
  • tests/src/test/snapshots/images/Paparazzi_Preview_Test_valueclass.previewparameters.android.composableskt.valueclasspreviewparameterpreview-8feqmps_float_1.png is excluded by !**/*.png
📒 Files selected for processing (5)
  • core/src/main/java/sergio/sastre/composable/preview/scanner/core/preview/ComposablePreviewInvocationHandler.kt
  • core/src/main/java/sergio/sastre/composable/preview/scanner/core/preview/ProvideComposablePreview.kt
  • tests/src/main/java/valueclass/previewparameters/android/Composables.kt
  • tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/api/main/screenshotid/ComposablePreviewToStringTest.kt
  • tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/paparazzi/runtime/PaparazziValueClassPreviewParameterInvokeTest.kt

@sergio-sastre

Copy link
Copy Markdown
Owner

@nshurtz Thanks for reporting the issue and opening a PR!
It looks great overall. I appreciate it! 😊

The only thing I am missing is to check that the changes work also in Roborazzi.

The new Composable sample that is used to reproduce the bug, seems that it is placed in a package that the Roborazzi tests do not scan. That’s also why a new Parameterized test was needed, the Roborazzi one is missing though.
The reason for this is, that I’ve seen in the past some code changes failing with only one of the Libraries due to the differences in how Paparazzi & Roborazzi download resources.

FYI, I am currently on holidays till the end of the week, but I promise to do the final check and release a bugfix version as soon as I am back. Sorry for the inconvenience! 🙏

Mirrors the Paparazzi test so the value-class @PreviewParameter fix is
verified under both rendering libraries, which download resources
differently. Scans the valueclass.previewparameters.android package and
records reference snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/roborazzi/runtime/RoborazziValueClassPreviewParameterInvokeTest.kt`:
- Around line 36-44: Update the values() parameter provider to check that
cachedPreviews is not empty before returning it, using
check(cachedPreviews.isNotEmpty()) so the test fails immediately when no
previews are discovered.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: edb736f5-0a98-4569-8285-cf564e60d29f

📥 Commits

Reviewing files that changed from the base of the PR and between 9d43037 and 22a699b.

⛔ Files ignored due to path filters (2)
  • tests/src/test/screenshots/valueclass/valueclass.previewparameters.android.ComposablesKt.ValueClassPreviewParameterPreview-8Feqmps_float_0.png is excluded by !**/*.png
  • tests/src/test/screenshots/valueclass/valueclass.previewparameters.android.ComposablesKt.ValueClassPreviewParameterPreview-8Feqmps_float_1.png is excluded by !**/*.png
📒 Files selected for processing (1)
  • tests/src/test/java/sergio/sastre/composable/preview/scanner/tests/roborazzi/runtime/RoborazziValueClassPreviewParameterInvokeTest.kt

@nshurtz

nshurtz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

For context on the boundaries of this fix: while verifying, I found that Android Studio's own preview renderer (androidx.compose.ui.tooling.ComposableInvoker) doesn't handle nullable value-class @PreviewParameters either — it throws IllegalArgumentException: argument type mismatch on them, because it passes the boxed value-class instance straight to Method.invoke without unboxing to the underlying type. This PR's invocation path renders them correctly.

I'm filing that (and the separate @PreviewParameter-with-default behavior) upstream with Google as Compose tooling issues; happy to link them here once openedIssue Tracker Link. This PR stays scoped to the scanner-side crash fix.

- For PreviewParameters Value-classes that are not nullable, it returns the underlying type. This returns the Value class name too for consistency

- Adds coverage for other types of PreviewParameter classes
@sergio-sastre

Copy link
Copy Markdown
Owner

@nshurtz Thanks for reporting the issue and opening a PR!

It looks great overall. I appreciate it! 😊

The only thing I am missing is to check that the changes work also in Roborazzi.

The new Composable sample that is used to reproduce the bug, seems that it is placed in a package that the Roborazzi tests do not scan. That’s also why a new Parameterized test was needed, the Roborazzi one is missing though.

The reason for this is, that I’ve seen in the past some code changes failing with only one of the Libraries due to the differences in how Paparazzi & Roborazzi download resources.

FYI, I am currently on holidays till the end of the week, but I promise to do the final check and release a bugfix version as soon as I am back. Sorry for the inconvenience! 🙏

I just moved the tests to keep the current structure, ensure that the name includes the Value Class name instead of the one of the underlying class, and add some extra tests to cover several @PreviewParameter naming edge cases.

I'll release a bugfix version as soon as I find the time, likely today or tomorrow.

Thank you very much for your contribution!

@sergio-sastre
sergio-sastre merged commit 76bd936 into sergio-sastre:master Aug 17, 2026
8 checks passed
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.

[BUG] Scanning/invoking a @Preview with a value class @PreviewParameter throws KotlinReflectionInternalError

2 participants