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

Support meta tags in StaticHTMLRenderer #483

Merged
merged 19 commits into from
May 23, 2022
Merged

Support meta tags in StaticHTMLRenderer #483

merged 19 commits into from
May 23, 2022

Conversation

AndrewBarba
Copy link
Contributor

@AndrewBarba AndrewBarba commented May 19, 2022

This PR adds the ability to control <head> tags using a new HTMLTitle view and HTMLMeta view. Taking inspiration from Next.js <NextHead>, you can use these views anywhere in your view hierarchy and they will be hoisted to the top <head> section of the html.

Use as a view:

var body: some View {
  VStack {
    ...
    HTMLTitle("Hello, Tokamak")
    HTMLMeta(charset: "utf-8")
    ...
  }
}

Use as a view modifier:

var body: some View {
  VStack {
    ...
  }
  .htmlTitle("Hello, Tokamak")
  .htmlMeta(charset: "utf-8")
}

And the resulting html (no matter where these are used in your view hierarchy):

<html>
  <head>
    <title>Hello, Tokamak</title>
    <meta charset="utf-8">
  </head>
  <body>
    ...
  </body>
</html>

@carson-katri
Copy link
Member

There was previously some discussion in #269 around designing a way to modify the head through a Scene similar to the Settings scene in SwiftUI:

struct MyApp : App {
  var body: some Scene {
    Head {
      Meta()
    }
    WindowGroup() {
      ContentView()
    }
  }
}

@MaxDesiatov MaxDesiatov added the enhancement New feature or request label May 19, 2022
@MaxDesiatov
Copy link
Collaborator

@AndrewBarba would do you think about the suggested Head/Meta API that uses types conforming to Scene? I also think that we should support the property attribute for OpenGraph meta tags.

@AndrewBarba
Copy link
Contributor Author

AndrewBarba commented May 19, 2022

I really like that API, that is certainly my preference. It's very similar to Next.js which I think is a good thing. The static render defines that base html string which I thought was odd but I just tried to fit that. I would much prefer if Scene actually rendered that surrounding html/head/body structure.

@MaxDesiatov
Copy link
Collaborator

Yeah, that would require bigger changes to the renderer though. Let me know if you'd like to implement them yourself or have any questions. I can pick this up next week otherwise.

@AndrewBarba
Copy link
Contributor Author

AndrewBarba commented May 19, 2022

I'm happy to do it in this PR I just need some direction (I don't know the codebase too well yet). If we keep the html string, what's the approach to pull out meta tags from the passed in view and render them in the head block?

Also happy to pass this off to you if you already know obvious approach here.

@MaxDesiatov
Copy link
Collaborator

MaxDesiatov commented May 19, 2022

The hardcoded HTML string is just a hack. I'd suggest replacing the static HTML string with a tree of HTML views that start not from the body, but the top-level <html> tag.

For that you'd initialize rootTarget in public init<V: View>(_ view: V, _ rootEnvironment: EnvironmentValues? = nil) with HTMLTarget(view, htmlTree), where htmlTree is the thing you build from the views, including your new Meta view you'd define with HTML children views in its body. These would be default views you build yourself for that initializer.

You could start with that, and introduce struct Head: Scene in a separate PR.

Let me know if any of this makes sense, I'm happy to provide more guidance if needed.

@AndrewBarba
Copy link
Contributor Author

AndrewBarba commented May 19, 2022

This kind of makes sense. I would expect a new initializer that takes in a Scene which defines the entire HTML structure - including Meta tags and titles? And then the existing init that just takes in a View would wrap that View with this new base HTML structure. I have some confusion around HTMLTarget, if I make a full htmlTree as the target then all the children seem to be overwritten. Im not sure what the main use of the HTMLTarget struct actually is to be honest.

@MaxDesiatov
Copy link
Collaborator

MaxDesiatov commented May 19, 2022

I would expect a new initializer that takes in a Scene which defines the entire HTML structure - including Meta tags and titles?

No need for a new initializer, I don't think one would pass Scene values instead of App values, and we already have an initializer for the latter.

I have some confusion around HTMLTarget, if I make a full htmlTree as the target then all the children seem to be overwritten. Im not sure what the main use of the HTMLTarget struct actually is to be honest.

Sorry about the confusion. HTMLTarget is a class that represents tree state the reconciler updates when needed. I misremembered the logic here, you're right, initial HTMLTarget value will always be overwritten. You'll have to modify extension WindowGroup: SceneDeferredToRenderer to wrap view in <head> and <body> tags instead.

@AndrewBarba
Copy link
Contributor Author

@MaxDesiatov @carson-katri Check it out, I refactored PR to use preference keys so now you can drop Title and Meta anywhere in your view hierarchy just like next.js and its hoisted to the top. I'm pretty happy with end result but let me know if you want me to add scripts, styles or anything else. Also very open to feedback on how I organized the files.

var body: some View {
  VStack {
    ...
    Title("Hello, Tokamak")
    Meta(charset: "utf-8")
    ...
  }
}

You can see the result working here: https://tokamak.app.swift.cloud

@MaxDesiatov
Copy link
Collaborator

MaxDesiatov commented May 20, 2022

Before I look into code just wanted to propose renaming Title and Meta to HTMLTitle and HTMLMeta, which would make it less ambiguous and more consistent with HTMLBody. WDYT?

I know the original issue had the short names, but I wonder if we should prepend HTML in case Title appears in some future SwiftUI version with a different API and semantics.

@AndrewBarba
Copy link
Contributor Author

AndrewBarba commented May 21, 2022

I agree, I think prefixing makes more sense. I made this change and updated the PR description to show the correct usage. I added usage for both views and view modifiers. SwiftUI on iOS uses a lot of modifiers, ex. .navigationBarTitle(), so I think it's important to support both.

@MaxDesiatov MaxDesiatov requested a review from carson-katri May 21, 2022 10:04
@@ -58,6 +58,12 @@ public final class StackReconciler<R: Renderer> {
*/
public let rootTarget: R.TargetType

/** A root renderer's main preference store
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: comment formatting

Suggested change
/** A root renderer's main preference store
/** A root renderer's main preference store.


import TokamakCore

internal struct HTMLTitlePreferenceKey: PreferenceKey {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We don't use internal in our codebase as it's the default visibility and is redundant.

Suggested change
internal struct HTMLTitlePreferenceKey: PreferenceKey {
struct HTMLTitlePreferenceKey: PreferenceKey {

Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

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

There are a few nits to pick, but overall this makes sense. One more question: how does this behave if there are multiple Title views in the same tree? Whichever comes last is applied I guess? Would you be able to add a snapshot test that confirms this? Thanks!

Copy link
Member

@carson-katri carson-katri left a comment

Choose a reason for hiding this comment

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

Overall it looks good to me, just needs a couple tests. I think they'd go in TokamakStaticHTMLTests/HTMLTests.swift

@carson-katri
Copy link
Member

Could you add a test for HTMLMeta too?

@AndrewBarba
Copy link
Contributor Author

Definitely - working on them now

@AndrewBarba
Copy link
Contributor Author

I pushed a test that uses 4 meta tags but only the last one ends up in the resulting html. Currently trying to debug this, I'm pretty sure my implementation is correct so I wonder if there's an issue with the preference store.

@AndrewBarba
Copy link
Contributor Author

AndrewBarba commented May 22, 2022

Okay the main issue here is that every mount target gets its own preference store and then we are trying to merge upwards anytime a child store changes. This is problematic because the merge only saves the value of the child and doesn't maintain any values in the parent for the same key.

I was able to fix this by:

  1. Change _PreferenceStore to a class
  2. Initialize MountedElement.preferenceStore to the parents store or create a new one
  3. Remove _PreferenceStore.merge as it should not be needed

I can push this up if you think it makes sense, otherwise I'm open to other approaches.

public class MountedTarget {
  var preferenceStore: _PreferenceStore

  ...

  init(_ app: _AnyApp, _ environmentValues: EnvironmentValues, _ parent: MountedElement<R>?) {
    element = .app(app)
    self.parent = parent
    self.preferenceStore = parent?.preferenceStore ?? .init()
    self.environmentValues = environmentValues
    viewTraits = .init()
    updateEnvironment()
  }
}

@MaxDesiatov
Copy link
Collaborator

MaxDesiatov commented May 23, 2022

Thanks for the investigation! IIRC @carson-katri is the author of preference store code, so I'll defer to him on this matter if he has a moment to review this.

@carson-katri
Copy link
Member

So does this make the child take all the values of the parent's preference store? If so, I don't think this behavior is correct as preference values flow up the tree not down. Similar to how an environment value from a child is not set on the parent, a preference value from a parent is not set on the child.

@AndrewBarba
Copy link
Contributor Author

Ah yes you are right. Don't think we have tests for this so I will add them.

So I think the approach to solve that is add a weak parent store to the _PreferenceStore. Each mount target will go back to getting its own store but the pref store will be updated to modify its parent store anytime one of its keys changes. This is still a better solution to merge, because the issue with merge is it doesn't know which keys changed, and so even with some fancy generic work to deep merge keys upwards, it was over adding values to the parent store because it was doing a full merge on ever didSet. If we control the upstream changes to only happen on inserting a key this should be more reliable.

…ee preference keys they should not have access to
@AndrewBarba
Copy link
Contributor Author

AndrewBarba commented May 23, 2022

@carson-katri I pushed that up, take a look let me know what you think. Im working on a test to cover that case now

@MaxDesiatov MaxDesiatov requested a review from carson-katri May 23, 2022 15:26
@AndrewBarba
Copy link
Contributor Author

Okay just pushed a test that I think correctly covers what you described.

carson-katri
carson-katri previously approved these changes May 23, 2022
Copy link
Member

@carson-katri carson-katri left a comment

Choose a reason for hiding this comment

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

That looks good to me, thanks!

Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

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

Overall this is great, one small change request: could you run SwiftFormat, and potentially have a pre-commit hook installed as described in the "Coding Style" section of CONTRIBUTING.md? Some of the newly added files use 4 spaces for indentation, while we use 2 spaces everywhere in the project.

Thanks!

@AndrewBarba
Copy link
Contributor Author

Yes definitely. Xcode is so annoying with the indents, I thought I caught them all. Will get this fixed

@MaxDesiatov
Copy link
Collaborator

Don't worry about catching them in Xcode manually. When I edit in Xcode I just write some messy inconsistent indentation, and it's all reformatted with pre-commit on every commit automatically 🙂

@AndrewBarba
Copy link
Contributor Author

Yeah wow - this is so much better. My bad for not reading that code style doc earlier

Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

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

Lovely, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Development

Successfully merging this pull request may close these issues.

3 participants