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

trial using context for renderingTarget #8704

Merged
merged 25 commits into from
Sep 8, 2023

Conversation

cemms1
Copy link
Contributor

@cemms1 cemms1 commented Aug 30, 2023

What does this change?

Trials using React Context for renderingTarget instead of heavy prop drilling.

Why?

This PR suggests how we might use React context for the scenario of a property set on entrypoint and that doesn't change throughout the component lifecycle.

Resolves #8711
Off the back of this discussion #8696

Additional notes

I've left the renderingTarget prop in for the components from ArticlePage -> StandardLayout / LiveLayout (via DecideLayout).

This is because it became quite tricky to decouple the new props structure of WebProps | AppsProps, due to the renderingTarget being used as the identifier for this discriminated union.
It may not be necessary to decouple though, since this is only a few layers of props and since in this case the props have a purpose (not using them simply to pass down to lower layers of the component tree).

@github-actions
Copy link

github-actions bot commented Aug 30, 2023

Size Change: 0 B 🆕

Total Size: 0 B

compressed-size-action

@cemms1 cemms1 force-pushed the cemms1/use-context-for-rendering-target branch from 3f494e9 to a2a5b54 Compare August 31, 2023 16:29
Comment on lines 93 to 118
export const ArticleAppsStory = () => {
return (
<Wrapper>
<ArticleMeta
format={{
display: ArticleDisplay.Standard,
design: ArticleDesign.Standard,
theme: Pillar.News,
}}
pageId=""
webTitle=""
byline="Lanre Bakare"
tags={tagsWithLargeBylineImage}
primaryDateline="Sun 12 Jan 2020 18.00 GMT"
secondaryDateline="Last modified on Sun 12 Jan 2020 21.00 GMT"
isCommentable={false}
discussionApiUrl=""
shortUrlId=""
ajaxUrl=""
showShareCount={true}
/>
</Wrapper>
);
};
ArticleAppsStory.args = { renderingContext: { target: 'Apps' } };

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding this additional story to demonstrate how we can use the renderingContextDecorator to override the default rendering context (which has been set as "Web" in the decorator itself)

Copy link
Contributor

Choose a reason for hiding this comment

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

I like this addition to demonstrate this! Perhaps we could put a link to it in a more centralised place of documentation as a reference of "how to override the default rendering context". Not sure where the best place is though 🤔

Comment on lines 1 to 10
import type { RenderingTarget } from './renderingTarget';

/**
* Context for global values (generic)
*
* This should not contain any properties which are likely to change between re-renders
*/
export interface RenderingContextType {
target: RenderingTarget;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any additional properties to the context must be added here.

We are still forming our lines in the sand but this could be a good place to add a linting rule to prevent unnecessary things from being added to the context if we want to do that?

Or potentially link out to a doc in the docstring?

Copy link
Contributor

@ioannakok ioannakok Sep 4, 2023

Choose a reason for hiding this comment

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

From #8696 and our lines in the sand sounds like there is already consensus in:

  • no usage of context in Islands
  • not be used for JSON responses or non JSX components

I am wondering whether we could create our own custom eslint rules for those two. Sounds like these are good cases for an automated check.

Copy link
Member

Choose a reason for hiding this comment

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

Custom ESLint rules can be done without too much hassle: https://stackoverflow.com/a/40979625/1577447

Comment on lines 1 to 19
import { RenderingContext } from '../../src/components/RenderingContext';

const defaultContext = { target: 'Web' };

export const RenderingContextDecorator = (
Story,
{ args: { renderingContext } },
) => {
const context = { ...defaultContext, ...renderingContext };

// For easy debugging
console.log('Storybook rendering context: \n', context);

return (
<RenderingContext.Provider value={context}>
<Story />
</RenderingContext.Provider>
);
};
Copy link
Contributor Author

@cemms1 cemms1 Aug 31, 2023

Choose a reason for hiding this comment

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

This Storybook decorator wraps all stories in a <RenderingContext.Provider> component, with the default value of the context provided which can be overridden at component level

@cemms1 cemms1 added run_chromatic Runs chromatic when label is applied and removed 🥒WiP Work in Progress labels Aug 31, 2023
@cemms1 cemms1 marked this pull request as ready for review August 31, 2023 17:18
@cemms1 cemms1 requested a review from a team as a code owner August 31, 2023 17:18
@ioannakok
Copy link
Contributor

ioannakok commented Sep 4, 2023

Thanks for raising the PR, putting thought on this taking the time to write the RFC! This makes sense to me for this occasion ("a global Context to DCR for truly global values" as per comment). Plus, it is always therapeutic to see repetitive code being removed. Just leaving some thoughts.

@ioannakok
Copy link
Contributor

As per the doc for deprecated ADRs should the following be part of this PR?

  1. Move this to historic ADRs and changing the status to Deprecated
  2. Creating an updated ADR?

@@ -137,10 +132,12 @@ export const BylineLink = ({
);
});

const { target } = useContext(RenderingContext);
Copy link
Contributor

Choose a reason for hiding this comment

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

Hope you don't mind some non-webex input here, but super supportive of using contexts in the specific cases of passing around some immutable data!

What would you think of wrapping the call useContext(RenderingContext) up into its own function? I copied something similar into AMP from the now-defunct AB test context.

const { target } = useRenderingTarget();

One enhancement off the back of this is you could pre-compute isApps and isWeb and optionally destructure them when you use the hook e.g.

const useRenderingTarget = () => {
  // ...
  return {
    target,
    isApps: target === "Apps",
    isWeb: target === "Web",
  };
};

// usages
const { isApps } = useRenderingTarget();
const { isWeb } = useRenderingTarget();

Copy link
Contributor

Choose a reason for hiding this comment

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

I like this idea!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely keen for non WebX input here as this repo is shared by many, thanks v much Chris!

Copy link
Contributor

@bryophyta bryophyta Sep 4, 2023

Choose a reason for hiding this comment

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

+1 on this suggested abstraction! I'm pretty sure I copied @chrislomaxjones's pattern from the AMP AB test code when I was putting together an RFC to use context for server-side tests.

Looking into that code also led me to How to use React Context effectively by Kent Dodds which I remember finding useful. Thought I'd link just in case it hasn't been surfaced in discussion already :)

fwiw, though I don't work with DCR anymore, rendering targets seem to me like a great use-case for Context and this looks like a great PR!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After trialing this solution, I do really like the derived values of context to help remove unnecessary logic from other places but think this would be best as a phase 2 type thing as this PR is a bit long already!

I've implemented what you've recommended @chrislomaxjones and @bryophyta in terms of the custom hook which is a better and safer use of context. The neat thing in that blog @bryophyta was avoiding specifying a default value and erroring with use outside of the provider. This has been super valuable input, thank you both!

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense, liking the useConfig naming a lot.

@cemms1 cemms1 added RFC and removed run_chromatic Runs chromatic when label is applied labels Sep 4, 2023
@cemms1
Copy link
Contributor Author

cemms1 commented Sep 6, 2023

This PR has changed quite substantially since my conversation with @JamieB-gu and @arelra earlier today, who both had very good feedback on the approach taken and naming conventions, thank you both! 🙌 .

To keep it clear what we're dealing with here, we've agreed on calling the context "config", since that most succinctly describes the type of information we are allowing to exist in this context. Config must be static and immutable (and must not change between renders). It cannot be used outside of the <ConfigProvider /> and therefore can only be used in React components.

It is impossible to separate islands (client-side rendered components) from SSR (server-side rendered) components so we now add a <ConfigProvider /> to the island hydration code. We supply the config prop in a very similar way to props, by stringifying the object in the <gu-island> component and un-stringifying (?) it when hydrating.

@cemms1 cemms1 added the run_chromatic Runs chromatic when label is applied label Sep 6, 2023
@@ -16,13 +18,14 @@ import { getProps } from './getProps';
* snapshots
*/
export const doStorybookHydration = () => {
document.querySelectorAll('gu-island').forEach((element) => {
for (const element of document.querySelectorAll('gu-island')) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is eslint automatically fixing on save

@@ -26,12 +28,14 @@ declare global {
* @param data The deserialised props we want to use for hydration
* @param element The location on the DOM where the component to hydrate exists
* @param emotionCache An instance of an emotion cache to use for the island
* @param config Application configuration to be passed to the config context for the hydrated component
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We are using the app config in a similar way to emotionCache

<>
<LightboxLayout
imageCount={article.imagesForLightbox.length}
<ConfigProvider value={configContext}>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The ArticlePage components are wrapped in the ConfigProvider

Comment on lines 87 to 93
/**
* Where is this coming from?
* Config value is set at high in the component tree within a React context
* @see /dotcom-rendering/src/components/ArticlePage.tsx or look for a `<ConfigProvider />`
*/
const { renderingTarget } = useConfig();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added these comments as an attempt to make this pattern easier to understand on first visit

props={JSON.stringify(children.props)}
clientOnly={clientOnly}
rootMargin={rootMargin}
config={JSON.stringify(config)}
Copy link
Member

Choose a reason for hiding this comment

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

Is this the exact same data serialised for each and every island?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this config value comes from the useConfig hook, so it's the same for every component in the same rendering request

Copy link
Contributor

Choose a reason for hiding this comment

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

We'll probably end up repeating the same data a lot as it's the same config for each island. I've added a comment to initHydration, but I wonder if we could put config in one place and have islands look it up from there, instead of passing it as an attribute to every island element?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The hydration code isn't JSX/React so I can't use a context hook there to fetch the config, which is why I thought of copying the same method that props uses.

The only way I can think to do this without repeating the config for each island is by putting the config on window.guardian, which is something I wanted to try to avoid if possible...

It might be that I'm trying to avoid it for no reason at all as this might be a better pattern to follow? Is there another way to do this without using window.guardian?

Copy link
Member

@mxdvl mxdvl Sep 7, 2023

Choose a reason for hiding this comment

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

You could serialise with JSON.stringify inside a single script tag with type application/json on the page?

Copy link
Contributor

@ioannakok ioannakok Sep 8, 2023

Choose a reason for hiding this comment

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

To @mxdvl's suggestion here's an example I came across recently of using the script tag in apps-rendering

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great suggestions, all. Thank you v much, this is very useful info.

As this PR has been around for a while now, I'm going to try to avoid this it ballooning in size so I'm going to treat this as an optimisation and have ticketed up here: #8795

Copy link
Contributor

@georgeblahblah georgeblahblah left a comment

Choose a reason for hiding this comment

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

This looks great! 🚀🚀🚀

I had a question/suggestion about copying config for each island, off the back of @mxdvl's question, but I don't think it's blocking.

The decision to allow use of context more generally rests on the following _"lines in the sand"_:

- Context should be considered **global, static, and immutable** for rendered components in dotcom-rendering. It can act as a type of configuration for our application.
- Context should **only be used for JSX components** (ie. not for JSON responses or non-JSX helper code)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm think I'm missing something, but I wasn't sure what this means in practice. Do we have any counter-examples of when/why context isn't good for these use-cases?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  • Context should be considered global, static, and immutable for rendered components in dotcom-rendering. It can act as a type of configuration for our application.

The reason we don't want it to be mutable is to avoid unnecessary re-renders caused by context changing as this could cause lots of issues

  • Context should only be used for JSX components (ie. not for JSON responses or non-JSX helper code)

We can't use the React context API for non-React components

Copy link
Contributor

Choose a reason for hiding this comment

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

makes sense! thank you!

dotcom-rendering/src/components/ArticlePage.tsx Outdated Show resolved Hide resolved
dotcom-rendering/src/components/FrontPage.tsx Outdated Show resolved Hide resolved
@@ -36,14 +37,15 @@ export const initHydration = async (
): Promise<void> => {
const name = getName(element);
const props = getProps(element);
const config = getConfig(element);
Copy link
Contributor

Choose a reason for hiding this comment

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

related to @mxdvl's question about if config will be the same for every island (which I think it will be), I wonder if we need to getConfig from this element, or if we could store page config once for the page, and each island picks it up from there instead of every island having an identical config attribute?

props={JSON.stringify(children.props)}
clientOnly={clientOnly}
rootMargin={rootMargin}
config={JSON.stringify(config)}
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll probably end up repeating the same data a lot as it's the same config for each island. I've added a comment to initHydration, but I wonder if we could put config in one place and have islands look it up from there, instead of passing it as an attribute to every island element?

Copy link
Contributor

@ioannakok ioannakok left a comment

Choose a reason for hiding this comment

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

Fantastic work!!! 🚀 Also agree with @mxdvl and @georgeblahblah's suggestions about islands but leaving this up to you. Well done!

Copy link
Member

@arelra arelra left a comment

Choose a reason for hiding this comment

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

Great work @cemms1 seeing this through!

Extracting out the serialisation of context outside of the component tree feels like a small optimisation that we can consider when/if we put a proper store in place that we can then facade with hooks.

@cemms1
Copy link
Contributor Author

cemms1 commented Sep 8, 2023

Accepted strange Chromatic diff for Vimeo component after double checking that the component looks fine on Storybook and in the article https://www.theguardian.com/film/2023/jan/25/oscars-2023-how-to-watch-almost-every-nominated-feature-doco-and-short-in-australia when running the application locally. The visual diff appears to only happen on Chromatic so is being treated as a false positive for now

@cemms1 cemms1 merged commit ad53a6b into main Sep 8, 2023
10 checks passed
@cemms1 cemms1 deleted the cemms1/use-context-for-rendering-target branch September 8, 2023 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
dotcom-rendering RFC run_chromatic Runs chromatic when label is applied
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Trial React context for renderingTarget
8 participants