160 Matching Annotations
  1. Jun 2023
  2. May 2023
  3. Apr 2023
    1. The paths must be relative to the project root. You should use ./ui/**/* so it also watches subfolders of ui.

      js module.exports = { watchPaths: ['./ui/**/*'] }

  4. Mar 2023
    1. ```js // Set up some pub/sub on the server

      import { EventEmitter } from "events"; export let emitter = new EventEmitter();

      // Set up an event stream with cleanup and queues // and stuff that subscribes to it and streams the // events when new stuff comes through:

      import { emitter } from "../some-emitter.server";

      type InitFunction = (send: SendFunction) => CleanupFunction; type SendFunction = (event: string, data: string) => void; type CleanupFunction = () => void;

      export function eventStream(request: Request, init: InitFunction) { let stream = new ReadableStream({ start(controller) { let encoder = new TextEncoder(); let send = (event: string, data: string) => { controller.enqueue(encoder.encode(event: ${event}\n)); controller.enqueue(encoder.encode(data: ${data}\n\n)); }; let cleanup = init(send);

        let closed = false;
        let close = () => {
          if (closed) return;
          cleanup();
          closed = true;
          request.signal.removeEventListener("abort", close);
          controller.close();
        };
      
        request.signal.addEventListener("abort", close);
        if (request.signal.aborted) {
          close();
          return;
        }
      },
      

      });

      return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); }

      // Return the event stream from a loader in // a resource route:

      import { eventStream } from "./event-stream";

      export let loader: LoaderFunction = ({ request }) => { return eventStream(request, send => { emitter.addListener("messageReceived", handleChatMessage);

      function handleChatMessage(chatMessage: string) {
        send("message", chatMessage);
      }
      
      return () => {
        emitter.removeListener("messageReceived", handleChatMessage);
      };
      

      }); };

      // Push into the event emitter in actions:

      import { emitter } from "./some-emitter.server";

      export let action: ActionFunction = async ({ request }) => { let formData = await request.formData(); emitter.emit("messageReceived", formData.get("something"); return { ok: true }; };

      // And finally, set up an EventSource in the browser

      function useEventSource(href: string) { let [data, setData] = useState("");

      useEffect(() => { let eventSource = new EventSource(href); eventSource.addEventListener("message", handler);

      function handler(event: MessageEvent) {
        setData(event.data || "unknown");
      }
      
      return () => {
        eventSource.removeEventListener("message", handler);
      };
      

      }, []);

      return data; } ```

    1. Put that TS code in a file your app imports, for example, in remix.env.d.ts, and now the type of name will be the expected one.

      ts declare module "@remix-run/server-runtime" { export interface AppLoadContext { name: string; } }

    1. ```js

      export const loader = async () => {

      // fire them all at once<br /> const critical1Promise = fetch('/test?text=critical1&delay=250').then(res => res.json()); const critical2Promise = fetch('/test?text=critical2&delay=500').then(res => res.json()); const lazyResolvedPromise = fetch('/test?text=lazyResolved&delay=100').then(res => res.json()); const lazy1Promise = fetch('/test?text=lazy1&delay=500').then(res => res.json()); const lazy2Promise = fetch('/test?text=lazy2&delay=1500').then(res => res.json()); const lazy3Promise = fetch('/test?text=lazy3&delay=2500').then(res => res.json()); const lazyErrorPromise = fetch('/test?text=lazy3&delay=3000').then(res => { throw Error('Oh noo!') });

      // await for the response return defer({ critical1: await critical1Promise, critical2: await critical2Promise, lazyResolved: lazyResolvedPromise, lazy1: lazy1Promise, lazy2: lazy2Promise, lazy3: lazy3Promise, lazyError: lazyErrorPromise }) } ```

  5. hydrogen.shopify.dev hydrogen.shopify.dev
    1. ```js import {defer} from "@shopify/remix-oxygen";

      export async function loader({ params: {handle}, context: {storefront} }) { const {product} = storefront.query({ query: #graphql query Product( $country: CountryCode, $language: LanguageCode, $handle: String! ) @inContext(country: $country, language: $language) product(handle: $handle) { id title }, variables: {handle}, cache: storefront.CacheLong() }); const {productRecommendations} = storefront.query({ query: #graphql query ProductRecommendations( $country: CountryCode, $language: LanguageCode, $handle: String! ) @inContext(country: $country, language: $language) productRecommendations(handle: $handle) { id title } }, variables: {handle} }); if (!product) { throw new Response('Not Found', { status: 404, }); } return defer({ product: await product, productRecommendations, }); } ```

    1. We present deferred data by using React Suspense to conditionally show the content when it's ready. Suspense provides a fallback element to show when the data is not yet ready. Normally a loading spinner would go here, but we can use that to show our streamed progress instead.

      js export default function Index() { const data = useLoaderData() const params = useParams() const stream = useEventSource( `/items/${params.hash}/progress`, { event: "progress", }, ) return ( <div> <Suspense fallback={<span> {stream}% </span>}> <Await resolve={data.promise} errorElement={<p>Error loading img!</p>} > {(promise) => <img alt="" src={promise.img} />} </Await> </Suspense> </div> ) }

    2. On the client, while we're waiting for our deferred promise to resolve, we can consume that stream to know how far along our process is.

      js const stream = useEventSource( `/items/${params.hash}/progress`, { event: "progress", }, )

    3. In Remix, we can use a resource route to make this endpoint, and our loader will return a stream that constant checks our JSON file for its progress.

      js export async function loader({ request, params, }: LoaderArgs) { const hash = params.hash return eventStream(request.signal, function setup(send) { const interval = setInterval(() => { const file = fs.readFileSync( path.join("public", "items", `${hash}.json`), ) if (file.toString()) { const data = JSON.parse(file.toString()) const progress = data.progress send({ event: "progress", data: String(progress) }) if (progress === 100) { clearInterval(interval) } } }, 200) return function clear(timer: number) { clearInterval(interval) clearInterval(timer) } }) }

    4. server sent events work by having an endpoint that does not immediately close its connection, and which sends a content type of text/event-stream.
    5. The loader defers a promise that will resolve only when the json's progress has hit 100.

      js export async function loader({ params }: LoaderArgs) { if (!params.hash) return redirect("/") const pathname = path.join( "public", "items", `${params.hash}.json`, ) const file = fs.readFileSync(pathname) if (!file) return redirect("/") const item = JSON.parse(file.toString()) if (!item) return redirect("/") if (item.progress === 100) { return defer({ promise: item, }) } return defer({ promise: new Promise((resolve) => { const interval = setInterval(() => { const file = fs.readFileSync(pathname) if (!file) return const item = JSON.parse(file.toString()) if (!item) return if (item.progress === 100) { clearInterval(interval) resolve(item) } return }) }), }) }

    6. Defer is a feature of Remix that allows you to return an unresolved Promise from a loader. The page will server-side render without waiting for the promise to resolve, and then when it finally does, the client will re-render with the new data.
    1. Remix uses the ?index parameter to indicate when a URL refers to the index route instead of the layout route
    1. Send the 304 Not Modified response

      ```js import etag from "etag"; import { renderToString } from "react-dom/server"; import type { EntryContext, HandleDataRequestFunction } from "remix"; import { RemixServer } from "remix";

      export default function handleRequest( request: Request, status: number, headers: Headers, remixContext: EntryContext ) { let markup = renderToString( <RemixServer context={remixContext} url={request.url} /> );

      headers.set("Content-Type", "text/html"); headers.set("ETag", etag(markup));

      // check if the If-None-Match header matches the ETag if (request.headers.get("If-None-Match") === headers.get("ETag")) { // and send an empty Response with status 304 and the headers. return new Response("", { status: 304, headers }); }

      return new Response("<!DOCTYPE html>" + markup, { status, headers }); }

      export let handleDataRequest: HandleDataRequestFunction = async ( response: Response, { request } ) => { let body = await response.text();

      if (request.method.toLowerCase() === "get") { response.headers.set("etag", etag(body)); // As with document requests, check the If-None-Match header // and compare it with the Etag, if they match, send the empty 304 Response if (request.headers.get("If-None-Match") === response.headers.get("ETag")) { return new Response("", { status: 304, headers: response.headers }); } }

      return response; }; ```

    2. All Together

      ```js import etag from "etag"; import { renderToString } from "react-dom/server"; import type { EntryContext, HandleDataRequestFunction } from "remix"; import { RemixServer } from "remix";

      export default function handleRequest( request: Request, status: number, headers: Headers, remixContext: EntryContext ) { let markup = renderToString( <RemixServer context={remixContext} url={request.url} /> );

      headers.set("Content-Type", "text/html"); headers.set("ETag", etag(markup));

      return new Response("<!DOCTYPE html>" + markup, { status, headers }); }

      export let handleDataRequest: HandleDataRequestFunction = async ( response: Response ) => { let body = await response.text(); response.headers.set("etag", etag(body)); return response; }; ```

    3. Using ETags for document requests

      ```js import etag from "etag"; import { renderToString } from "react-dom/server"; import type { EntryContext } from "remix"; import { RemixServer } from "remix";

      export default function handleRequest( request: Request, status: number, headers: Headers, remixContext: EntryContext ) { let markup = renderToString( <RemixServer context={remixContext} url={request.url} /> );

      headers.set("Content-Type", "text/html"); // add the Etag header using the markup as value headers.set("ETag", etag(markup));

      return new Response("<!DOCTYPE html>" + markup, { status, headers }); } ```

    4. Using ETags for data requests

      ```js import etag from "etag"; import type { HandleDataRequestFunction } from "remix";

      export let handleDataRequest: HandleDataRequestFunction = async ( response: Response, { request } ) => { let body = await response.text(); // parse the response body as text

      // only add the ETag for GET requests if (request.method.toLowerCase() === "get") { response.headers.set("etag", etag(body)); // and use it to create the ETag }

      return response; // return the response }; ```

    1. ```js import { renderToString } from "react-dom/server"; import { RemixServer } from "remix"; import type { EntryContext } from "remix"; import { etag } from 'remix-etag';

      export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) { const markup = renderToString( <RemixServer context={remixContext} url={request.url} /> );

      responseHeaders.set("Content-Type", "text/html");

      const response = new Response("<!DOCTYPE html>" + markup, { status: responseStatusCode, headers: responseHeaders, }); return etag({ request, response }); } ```

    1. ```js import { renderToReadableStream } from 'react-dom/server'; import type { EntryContext } from '@remix-run/cloudflare'; import { RemixServer } from '@remix-run/react'; import { renderHeadToString } from 'remix-island'; import { Head } from './root';

      const readableString = (value: string) => { const te = new TextEncoder(); return new ReadableStream({ start(controller) { controller.enqueue(te.encode(value)); controller.close(); }, }); };

      export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, ) { const { readable, writable } = new TransformStream(); const head = readableString( <!DOCTYPE html><html><head>${renderHeadToString({ request, remixContext, Head, })}</head><body><div id="root">, ); const end = readableString(</div></body></html>);

      const body = await renderToReadableStream( <RemixServer context={remixContext} url={request.url} />, );

      Promise.resolve() .then(() => head.pipeTo(writable, { preventClose: true })) .then(() => body.pipeTo(writable, { preventClose: true })) .then(() => end.pipeTo(writable));

      responseHeaders.set('Content-Type', 'text/html');

      return new Response(readable, { status: responseStatusCode, headers: responseHeaders, }); } ```

    1. You'll notice that for the app/routes/jokes/$jokeId.tsx route in addition to Cache-Control we've also set Vary header to Cookie. This is because we're returning something that's specific to the user who is logged in. So we want the cache to associated to that particular Cookie value and not shared with different users, so the browser and CDN will not deliver the cached value if the cookie is different from the cached response's cookie.
    1. ```js import parseCacheControl from "parse-cache-control";

      export function headers({ loaderHeaders, parentHeaders, }: { loaderHeaders: Headers; parentHeaders: Headers; }) { const loaderCache = parseCacheControl( loaderHeaders.get("Cache-Control") ); const parentCache = parseCacheControl( parentHeaders.get("Cache-Control") );

      // take the most conservative between the parent and loader, otherwise // we'll be too aggressive for one of them. const maxAge = Math.min( loaderCache["max-age"], parentCache["max-age"] );

      return { "Cache-Control": max-age=${maxAge}, }; } ```


      ```js import { renderToString } from "react-dom/server"; import { RemixServer } from "@remix-run/react"; import type { EntryContext } from "@remix-run/node"; // or cloudflare/deno

      export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) { const markup = renderToString( <RemixServer context={remixContext} url={request.url} /> );

      responseHeaders.set("Content-Type", "text/html"); responseHeaders.set("X-Powered-By", "Hugs");

      return new Response("<!DOCTYPE html>" + markup, { status: responseStatusCode, headers: responseHeaders, }); } ```

  6. Feb 2023
    1. ```js import type { EntryContext } from "@remix-run/cloudflare"; import { RemixServer } from "@remix-run/react"; import isbot from "isbot"; import { renderToReadableStream } from "react-dom/server";

      const ABORT_DELAY = 5000;

      const handleRequest = async ( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) => { let didError = false;

      const stream = await renderToReadableStream( <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />, { onError: (error: unknown) => { didError = true; console.error(error);

          // You can also log crash/error report
        },
        signal: AbortSignal.timeout(ABORT_DELAY),
      }
      

      );

      if (isbot(request.headers.get("user-agent"))) { await stream.allReady; }

      responseHeaders.set("Content-Type", "text/html"); return new Response(stream, { headers: responseHeaders, status: didError ? 500 : responseStatusCode, }); };

      export default handleRequest; ```

    1. I resolved this by changing the imports from @remix-run/node to @remix-run/cloudflare

      diff - import { json } from "@remix-run/node"; + import { json } from "@remix-run/cloudflare";

  7. Dec 2022
    1. I had been wrapping my components with an improper tag that is, NextJS is not comfortable having a p tag wrapping your divs, sections etc so it will yell "Hydration failed because the initial UI does not match what was rendered on the server". So I solved this problem by examining how my elements were wrapping each other.
  8. Nov 2022
    1. If we're talking "load more" a much simpler way to do it is to consider how you'd do it old school with no JS. Just return all pages according to the search param, so "?page=3" would return all three pages. More kB over the network, but far easier to implement. There's even a product reason to do it this way: when you load more you automatically update the comment counts/points of each entry, so maybe it's worth it.
    2. Location state, unlike typical "global state", automatically persists across both refreshes and back/forward button clicks, but dies with the session, so scroll restoration will work as expected even for a refresh! Built in browser APIs tend to be a bit more resilient than storing stuff in application memory, they also keep your bundle smaller.
    3. There are various ways to use it (declaratively, imperatively, etc.), but simplest way to explain is to imagine a "Load more" button that's really just a link like this:`<Link to="?page=2" state={{ data }} />`Your Remix loader would load page 2 from the url search param and your component would concat that onto `location.state.data` (the previous entries from the initial location). This renders the old data and the new data together.
  9. Oct 2022
  10. Sep 2022
  11. Jul 2022
  12. Jan 2022