sjc5 / hwy

Hwy is a fullstack web framework for driving a (p)react frontend with a Go backend. Includes end-to-end typesafety, file-based nested UI routing, and much more.
BSD 3-Clause "New" or "Revised" License
317 stars 3 forks source link

Client side ts files #15

Closed donferi closed 11 months ago

donferi commented 11 months ago

Hello, is there a way to introduce something like filename.client.ts sibling to a *.page.tsx so that that client file gets transpiled and automatically added to a page?

Sometimes I need some client side js here and there and would love to have a clean way to add it per-page.

I would love to be able to do something like onClick={someFnInFilename.client.ts}. It would be even better if I can import it so that I don't have to use globals like window.someFnInFilename but that may be impossible to implement.

Thanks!!

sjc5 commented 11 months ago

Hey there @donferi, thanks for the issue!

I like the idea of adding some helpers to make this more clear, but this is possible today doing something like the below. Would love to hear any thoughts on API design / what the best experience would be for this.

So, you can co-locate TS files anywhere, including in the pages folder, just by excluding the .page. in the file name.

If you want to use a function client-side, you can create some super simple helpers for various use cases.

For example, here is an arbitrary function you want to run on the client, originating from a TS file inside pages:

src/pages/client-script.ts

function alertYo() {
  alert("Yo this is from src/pages/client-script.ts!")
}

export { alertYo }

For a button inside a page component, you can do:

src/pages/_index.page.tsx

import { alertYo } from "./client-script.ts"

function toClientHandler(fn: () => void) {
  return `${fn}${fn.name}();`
}

export default function () {
  return (
    <button onClick={toClientHandler(alertYo)}>Click me</button>
  )
}

And if you want to run a script eagerly, you can do:

import { alertYo } from "./client-script.ts"

function toClientHandler(fn: () => void) {
  return `${fn}${fn.name}();`
}

function RunDangerouslyAndEagerlyOnClient({ fn }: { fn: () => void }) {
  return <script dangerouslySetInnerHTML={{ __html: toClientHandler(fn) }} />
}

export default function () {
  return (
    <>
      <RunDangerouslyAndEagerlyOnClient fn={alertYo} />

      <OtherMarkup />
    </>
  )
}

What do you think, @donferi?

donferi commented 11 months ago

Hey @sjc5 thanks for your fast reply!

I appreciate your approach, got the alert working! Thank you! Btw I tried this with bun and it didn't work, but worked great on node!

My only hesitation with this approach is that since its getting the literal fn string you lose things like scope and passing args around is a bit trickier too.

For eg:

let count = 0;

function alertYo() {
  alert(`Yo this is from src/pages/client-script.ts! Count: ${count}`)
}

export { alertYo }

I'm thinking on how this could be done but I'm running out of ideas 😅. It could be that this gets added as a <script> after processing but then we wouldn't be able to reference the fn in the server jsx 🤔

sjc5 commented 11 months ago

I see what you mean @donferi.

What do you think of this? This one should work in Bun too.

Client script file

src/pages/client-script.ts

/// <reference lib="dom" />

let count = 0;

function increment(elementId: string) {
  count++;

  const countEl = document.getElementById(elementId);

  if (!countEl) {
    return;
  }

  countEl.innerHTML = count.toString();
}

export { count, increment };

Hwy page component file

src/pages/_index.page.tsx

import { count, increment } from "./client-script.js";

// potentially from "@hwy-js/client"
import { InitClientScope, toClientHandler } from "./client-utils.js";

export default function () {
  const elementId = "count";

  return (
    <>
      <InitClientScope values={{ count, increment }} />

      <button onclick={toClientHandler(increment, elementId)}>Click me</button>

      <div id={elementId}>{count}</div>
    </>
  );
}

Note that toClientHandler above takes the function as the first param, and then any arguments to that function as additional rest params (typesafe from the generic).

Utils (bake into framework?)

This could potentially be baked into the framework and imported like:

import { InitClientScope, toClientHandler } from "@hwy-js/client"

src/pages/client-utils.tsx

// NOTE: Would need to be robustified for different value types to avoid `[object Object]`
function InitClientScope({ values }: { values: Record<string, any> }) {
  return (
    <script
      dangerouslySetInnerHTML={{
        __html: `${Object.entries(values)
          .map(([key, value]) => `let ${key} = ${value}`)
          .join(";\n")}`,
      }}
    />
  );
}

function toClientHandler<Args extends any[]>(
  fn: (...args: Args) => void,
  ...args: Args
): string {
  const argsStr =
    args.length > 0 ? args.map((arg) => JSON.stringify(arg)).join(", ") : "";
  return `${fn.name}(${argsStr});`;
}

export { InitClientScope, toClientHandler };

A lot of this could certainly be accomplished through new build-step magic, but that opens a bit of a can of worms around things like scope and code splitting, and might detract a bit from the magic of the HTMX mental model. I'm not sure if doing this in the build step would be worth the extra complexity to the framework architecture, but I'm definitely open to thinking more about it. I think with things like HTMX you want to usually rely on server-side state, and then reach for things like Alpine, or perhaps utils like the ones proposed above, when you need client interactivity. It's kind of a mental model shift from React, etc.

But that said, some utilities / patterns like the above (or whatever we land on) would be nice for those times when you do want to do purely client side stuff, but don't want to reach for Alpine.

What do you think, @donferi?

sjc5 commented 11 months ago

Actually I see what's happening -- the above has an issue in Bun too because for some reason it is stripping the function name. Will have to get that sorted, not sure why it's doing that only in Bun... they both should be using the same esbuild settings 🤔

donferi commented 11 months ago

@sjc5 Thanks you so much again!

I tried this and this does work! I'm having the same realization than you, I'm honestly not sure if its worth it.

I'm not a fan of the Alpine + JSX combo because things like @click or x-on:click.once are not valid jsx 😕

What about keeping it simple and doing something like about.client.ts and that gets processed and included in a <script> on about.page.tsx? 🤔

In there we can just do vanilla JS, add event listeners manually instead of trying to pass a "reference" around. This way we can at least write some TS and include it on a specific page without over-complicating everything and deviating from htmx too much.

I'm just not sure how it works with things like hx-boost, ideally we unload the page scripts we are no longer using.

What love to hear your thoughts on this.

sjc5 commented 11 months ago

I think that's probably do-able @donferi

One approach (pseudo-code) could be:

let Component = pageExport.default

if (fileWithSameNameDotClient) {
  Component = (props: CompProps) => {
    return (
      <>
        <ExtraClientScript />
        {await pageExport.default(props)}
      </>
    )
  }
}

One problem with the above is that it makes the script a sibling to the page component, not a child. But we don't want to mess with component structure or add extra wrapper elements.

Could also probably toss it right into the document head. We wouldn't be able to bundle because that would either limit deployment targets or require doing a bunch of permutational work at build time. But could just do sequential script tags in the document head for all live page components. This approach would avoid the component structure problem above too.

donferi commented 11 months ago

That sounds like a perfectly reasonable first iteration to me. 👍

sjc5 commented 11 months ago

Nice, thanks for talking it through! I'll give this a crack when I get some time. It shouldn't be too long to proof of concept I don't think.

sjc5 commented 11 months ago

Confirming that we can make this API work:

src/pages/whatever.client.ts

/// <reference lib="dom" />

const el = document.getElementById("button");

let count = 0;

function increment() {
  if (el) {
    count++;
    el.innerHTML = count.toString();
  }
}

src/pages/whatever.page.tsx

export default function () {
  return (
    // Number will increment when you click
    <button id="button" onclick="increment();">
      0
    </button>
  );
}

If you do this across multiple nested routes, the scripts will order from highest ancestor to lowest child in the document head. It will be the developer's responsibility to avoid overwriting variables in various scripts, as these scripts essentially become global scope, but that seems reasonable to me. It also makes it nice in that you can just do onclick="increment();" in your JSX and it will work.

Even though this solution is not too far off from just doing the same at a global level in your main client.entry.ts, it enables you to (1) do what is more or less manual code splitting and (2) co-locate your client scripts.

As you stated above, this should be mostly an escape hatch and not a core way of working. If it became a core way of working I'd probably reach back for React / Remix 🙂