Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix[qwik]: ENG-7299 Default value not updating for custom components on ContentEditor #3947

Open
wants to merge 26 commits into
base: main
Choose a base branch
from

Conversation

yash-builder
Copy link
Contributor

Description

This PR addresses a critical reactivity issue in the Qwik SDK where deeply nested content updates weren't properly triggering.

The core problem was that when content was updated through the Builder.io editor, the changes were correctly stored in state but weren't causing components to re-render.

I've implemented a key-based approach for the InteractiveElement component that uses a dynamic key derived from the component options, forcing Qwik to update it's component when options changes. This effectively bypasses limitations in Qwik's resumability model where prop changes to dynamic components aren't always detected. While using JSON.stringify in keys isn't ideal for performance, but it provides a reliable solution that ensures content updates are immediately reflected in the UI without requiring page refreshes.

JIRA
https://builder-io.atlassian.net/browse/ENG-7299

Loom
https://www.loom.com/share/2b4d1b2872fe4151b1a9f2f131bab780?sid=7fc0f143-0623-41f4-aa94-e947530dde55

@yash-builder yash-builder added the bug Something isn't working label Mar 5, 2025
@yash-builder yash-builder requested a review from a team March 5, 2025 12:29
@yash-builder yash-builder self-assigned this Mar 5, 2025
@yash-builder yash-builder requested review from samijaber and removed request for a team March 5, 2025 12:29
Copy link

changeset-bot bot commented Mar 5, 2025

🦋 Changeset detected

Latest commit: 7bb3f45

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

This PR includes changesets to release 1 package
Name Type
@builder.io/sdk-qwik 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

Copy link

gitguardian bot commented Mar 6, 2025

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
2708648 Triggered Generic High Entropy Secret 3c65c71 examples/angular-gen2/src/environments/environment.ts View secret
314150 Triggered Generic High Entropy Secret 126bcfc packages/sdks/src/functions/get-content/snapshots/generate-content-url.test.ts.snap View secret
11707119 Triggered Generic High Entropy Secret 126bcfc packages/sdks/snippets/react-sdk-next-14-app/app/custom-child/page.tsx View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Copy link

nx-cloud bot commented Mar 6, 2025

View your CI Pipeline Execution ↗ for commit 7bb3f45.

Command Status Duration Result
nx test @e2e/vue ❌ Failed 4m 23s View ↗
nx test @e2e/angular-16 ✅ Succeeded 8m 12s View ↗
nx test @e2e/qwik-city ✅ Succeeded 8m 32s View ↗
nx test @e2e/nextjs-sdk-next-app ✅ Succeeded 8m 11s View ↗
nx test @e2e/nuxt ✅ Succeeded 8m 4s View ↗
nx test @e2e/angular-16-ssr ✅ Succeeded 7m 15s View ↗
nx test @e2e/hydrogen ✅ Succeeded 6m 13s View ↗
nx test @e2e/react-sdk-next-15-app ✅ Succeeded 6m 8s View ↗
Additional runs (36) ✅ Succeeded ... View ↗

☁️ Nx Cloud last updated this comment at 2025-03-28 11:54:01 UTC

…rTimeout for locator assertion as recommended
json: {
post: (json) => {
if (json.name === 'InteractiveElement') {
console.log('json', JSON.stringify(json, null, 2));
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
console.log('json', JSON.stringify(json, null, 2));

Comment on lines 346 to 352
await expect(
page.frameLocator('iframe').getByText('Item 1')
).toBeVisible({ timeout: 5000 });

// Re-query the item1 element as it might have been recreated
const updatedItem1 = page.frameLocator('iframe').getByText('Item 1');
await updatedItem1.click();
Copy link
Contributor

Choose a reason for hiding this comment

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

we want to avoid hardcoded timeouts unless there's a special reason for them. Playwright has automated waiting and retrying so that we don't need to provide hardcoded timeout values all the time. Also, in this case, the default is already 5000 so this does nothing:

timeout: testType === 'snippet' ? 30000 : 5000,

you also don't need to redefine the locator twice:

Suggested change
await expect(
page.frameLocator('iframe').getByText('Item 1')
).toBeVisible({ timeout: 5000 });
// Re-query the item1 element as it might have been recreated
const updatedItem1 = page.frameLocator('iframe').getByText('Item 1');
await updatedItem1.click();
// Re-query the item1 element as it might have been recreated
const updatedItem1 = page.frameLocator('iframe').getByText('Item 1');
await expect(updatedItem1).toBeVisible();
await updatedItem1.click();

@yash-builder
Copy link
Contributor Author

Hey @samijaber I have went thru lot of changes the test for Qwik runs on my local but I am quite unsure why it keeps on failing in CI. I am attaching loom for the reference

Loom

@yash-builder yash-builder requested a review from samijaber March 27, 2025 07:25
@@ -9,7 +9,7 @@ const LAST_COMPONENT_REGISTERED_MESSAGE =

test.describe('Custom components', () => {
test('correctly renders custom component', async ({ page, packageName, sdk }) => {
test.skip(!['angular', 'react'].includes(sdk));
test.skip(!['angular', 'react', 'qwik-city'].includes(sdk));
Copy link
Contributor

Choose a reason for hiding this comment

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

this line doesn't have any impact, sdk can be qwik. packageNames are the different e2e/snippet server names and can be qwik-city

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh nice catch....I was testing somethings...I forgot to remove this

Comment on lines +347 to +359

// Re-query the item1 element as it might have been recreated
const updatedItem1 = page.frameLocator('iframe').getByText('Item 1');
await expect(updatedItem1).toBeVisible();
await expect(updatedItem1).toBeEnabled();
await expect(updatedItem1).toBeInViewport();
await updatedItem1.click();

const detailElement = page.frameLocator('iframe').getByText(NEW_DETAILS_TEXT);
await detailElement.waitFor();
await expect(detailElement).toBeVisible();

const [titleBox, detailBox] = await Promise.all([
item1.boundingBox(),
updatedItem1.boundingBox(),
Copy link
Contributor

Choose a reason for hiding this comment

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

no need to update this right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope!

@@ -46,6 +48,25 @@ const builderBlockWithClassNameCustomComponent = {
],
};

const CUSTOM_COMPONENT = [
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const CUSTOM_COMPONENT = [
const CUSTOM_COMPONENTS = [

Comment on lines +53 to +56
name: 'Hello',
component: Hello,
inputs: [],
},
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you added the Hello component for this test:https://github.com/sidmohanty11/builder/blob/general-support-vc/packages/sdks-tests/src/e2e-tests/custom-components.spec.ts#L11 right? you will also need to update the content of it from just hello to hello World

Comment on lines +156 to +164
test('correctly updating custom components when default value is not set', async ({ page, basePort, sdk, packageName }) => {
test.skip(!['react', 'qwik-city'].includes(packageName));
await launchEmbedderAndWaitForSdk({ path: '/custom-components-no-default-value', basePort, page, sdk });
const newContent = cloneContent(CUSTOM_COMPONENT_NO_DEFAULT_VALUE);
newContent.data.blocks[0].component.options.text = "Hello";
await sendContentUpdateMessage({ page, newContent, model: 'page' });
const helloWorldText = page.frameLocator('iframe').locator('[builder-id="builder-d01784d1ca964e0da958ab4a4a891b08"]')
await expect(helloWorldText).toHaveText('Hello');
})
Copy link
Contributor

Choose a reason for hiding this comment

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

by default it should render Hello right? because you're passing it as defaultValue? without needing to send the content update message?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Didn't get you...🤔
I am answering this questions based on my understanding. Yes, by default it should render hello. defaultValue has not been passed yet if you see CUSTOM_COMPONENT_NO_DEFAULT_VALUE the object will look like this

component: {
    name: 'Description',
    options: {},
}

we will be requiring sendContentUpdateMessage to update the text message from "" to "Hello"

Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't this: https://github.com/BuilderIO/builder/pull/3947/files#diff-f17fc2e9e6719ba501c3ab4df9c1fef742dc2c5dd3f709c622f6c79cdffcc1bdR99 add the defaultValue? i'm confused because on initial render if there is no initial value provided and we are providing a default value - we should be fallbacking to that

Copy link
Contributor

@samijaber samijaber Mar 28, 2025

Choose a reason for hiding this comment

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

@sidmohanty11 your confusion stems from the fact that default values are only used when the block is created in the visual editor to determine the initial value for inputs: they are not used by the SDK at run-time.

@yash-builder one way to clear up such confusion is to avoid using the exact same values for different things. Have a defaultValue: 'FOO' and update the text here to Hello instead of having the exact same string Hello in both cases.

Also, as a general rule, when we add a test that involves editing content, i think it's best that it looks like this:

  • load initial content
  • check the initial value of a block
  • make an update to that block
  • check the value of the same block, and see that it changed as expected

In this test you're not checking the initial state of the block. You could add a toBeEmpty() check:

    test.skip(!['react', 'qwik-city'].includes(packageName));
    await launchEmbedderAndWaitForSdk({ path: '/custom-components-no-default-value', basePort, page, sdk });

    // check initial value here
    const helloWorldText = page.frameLocator('iframe').locator('[builder-id="builder-d01784d1ca964e0da958ab4a4a891b08"]')
    await expect(helloWorldText).toBeEmpty();

    const newContent = cloneContent(CUSTOM_COMPONENT_NO_DEFAULT_VALUE);
    newContent.data.blocks[0].component.options.text = "Hello";
    await sendContentUpdateMessage({ page, newContent, model: 'page' });
    const helloWorldText = page.frameLocator('iframe').locator('[builder-id="builder-d01784d1ca964e0da958ab4a4a891b08"]')
    await expect(helloWorldText).toHaveText('Hello');

Not a huge deal though, more of a general rule of thumb for these types of tests

Comment on lines +156 to +164
test('correctly updating custom components when default value is not set', async ({ page, basePort, sdk, packageName }) => {
test.skip(!['react', 'qwik-city'].includes(packageName));
await launchEmbedderAndWaitForSdk({ path: '/custom-components-no-default-value', basePort, page, sdk });
const newContent = cloneContent(CUSTOM_COMPONENT_NO_DEFAULT_VALUE);
newContent.data.blocks[0].component.options.text = "Hello";
await sendContentUpdateMessage({ page, newContent, model: 'page' });
const helloWorldText = page.frameLocator('iframe').locator('[builder-id="builder-d01784d1ca964e0da958ab4a4a891b08"]')
await expect(helloWorldText).toHaveText('Hello');
})
Copy link
Contributor

@samijaber samijaber Mar 28, 2025

Choose a reason for hiding this comment

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

@sidmohanty11 your confusion stems from the fact that default values are only used when the block is created in the visual editor to determine the initial value for inputs: they are not used by the SDK at run-time.

@yash-builder one way to clear up such confusion is to avoid using the exact same values for different things. Have a defaultValue: 'FOO' and update the text here to Hello instead of having the exact same string Hello in both cases.

Also, as a general rule, when we add a test that involves editing content, i think it's best that it looks like this:

  • load initial content
  • check the initial value of a block
  • make an update to that block
  • check the value of the same block, and see that it changed as expected

In this test you're not checking the initial state of the block. You could add a toBeEmpty() check:

    test.skip(!['react', 'qwik-city'].includes(packageName));
    await launchEmbedderAndWaitForSdk({ path: '/custom-components-no-default-value', basePort, page, sdk });

    // check initial value here
    const helloWorldText = page.frameLocator('iframe').locator('[builder-id="builder-d01784d1ca964e0da958ab4a4a891b08"]')
    await expect(helloWorldText).toBeEmpty();

    const newContent = cloneContent(CUSTOM_COMPONENT_NO_DEFAULT_VALUE);
    newContent.data.blocks[0].component.options.text = "Hello";
    await sendContentUpdateMessage({ page, newContent, model: 'page' });
    const helloWorldText = page.frameLocator('iframe').locator('[builder-id="builder-d01784d1ca964e0da958ab4a4a891b08"]')
    await expect(helloWorldText).toHaveText('Hello');

Not a huge deal though, more of a general rule of thumb for these types of tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants