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

feat(islands): Allow importing islands outside of project with hint comment #1997

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions docs/canary/concepts/islands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
---
description: |
Islands enable client side interactivity in Fresh. They are hydrated on the client in addition to being rendered on the server.
---

Islands enable client side interactivity in Fresh. Islands are isolated Preact
components that are rendered on the server and then hydrated on the client. This
is different from all other components in Fresh, as they are usually rendered on
the server only.

Islands are defined by creating a file in the `islands/` folder in a Fresh
project. The name of this file must be a PascalCase or kebab-case name of the
island.

```tsx islands/my-island.tsx
import { useSignal } from "@preact/signals";

export default function MyIsland() {
const count = useSignal(0);

return (
<div>
Counter is at {count}.{" "}
<button onClick={() => (count.value += 1)}>+</button>
</div>
);
}
```

An island can be used in a page like a regular Preact component. Fresh will take
care of automatically re-hydrating the island on the client.

```tsx route/index.tsx
import MyIsland from "../islands/my-island.tsx";

export default function Home() {
return <MyIsland />;
}
```

To use islands outside of the islands directory, use the `@fresh-island` hint
and the import will be treated as an island. This works for URL imports as well
as relative imports.

```tsx route/index.tsx
import { useSignal } from "@preact/signals";
// @fresh-island
import Counter from "https://deno.land/x/[email protected]/demo/islands/Counter.tsx";
// @fresh-island
import SharedIsland from "../../shared/islands/SharedIsland.tsx";

export default function Home() {
const count = useSignal(3);
return (
<div>
<Counter count={count} />
<SharedIsland />
</div>
);
}
```

## Passing JSX to islands

Islands support passing JSX elements via the `children` property.

```tsx islands/my-island.tsx
import { useSignal } from "@preact/signals";
import { ComponentChildren } from "preact";

interface Props {
children: ComponentChildren;
}

export default function MyIsland({ children }: Props) {
const count = useSignal(0);

return (
<div>
Counter is at {count}.{" "}
<button onClick={() => (count.value += 1)}>+</button>
{children}
</div>
);
}
```

This allows you to pass static content rendered by the server to an island in
the browser.

```tsx routes/index.tsx
import MyIsland from "../islands/my-island.tsx";

export default function Home() {
return (
<MyIsland>
<p>This text is rendered on the server</p>
</MyIsland>
);
}
```

## Passing other props to islands

Passing props to islands is supported, but only if the props are serializable.
Fresh can serialize the following types of values:

- Primitive types `string`, `boolean`, `bigint`, and `null`
- Most `number`s (`Infinity`, `-Infinity`, and `NaN` are silently converted to
`null`)
- Plain objects with string keys and serializable values
- Arrays containing serializable values
- Uint8Array
- JSX Elements (restricted to `props.children`)
- Preact Signals (if the inner value is serializable)

Circular references are supported. If an object or signal is referenced multiple
times, it is only serialized once and the references are restored upon
deserialization. Passing complex objects like `Date`, custom classes, or
functions is not supported.

During server side rendering, Fresh annotates the HTML with special comments
that indicate where each island will go. This gives the code sent to the client
enough information to put the islands where they are supposed to go without
requiring hydration for the static children of interactive islands. No
Javascript is sent to the client when no interactivity is needed.

```html
<!--frsh-myisland_default:default:0-->
<div>
Counter is at 0.
<button>+</button>
<!--frsh-slot-myisland_default:children-->
<p>This text is rendered on the server</p>
<!--/frsh-slot-myisland_default:children-->
</div>
<!--/frsh-myisland_default:default:0-->
```

### Nesting islands

Islands can be nested within other islands as well. In that scenario they act
like a normal Preact component, but still receive the serialized props if any
were present.

```tsx islands/other-island.tsx
import { useSignal } from "@preact/signals";
import { ComponentChildren } from "preact";

interface Props {
children: ComponentChildren;
foo: string;
}

function randomNumber() {
return Math.floor(Math.random() * 100);
}

export default function MyIsland({ children, foo }: Props) {
const number = useSignal(randomNumber());

return (
<div>
<p>String from props: {foo}</p>
<p>
<button onClick={() => (number.value = randomNumber())}>Random</button>
{" "}
number is: {number}.
</p>
</div>
);
}
```

In essence, Fresh allows you to mix static and interactive parts in your app in
a way that's most optimal for your use app. We'll keep sending only the
JavaScript that is needed for the islands to the browser.

```tsx route/index.tsx
import MyIsland from "../islands/my-island.tsx";
import OtherIsland from "../islands/other-island.tsx";

export default function Home() {
return (
<div>
<MyIsland>
<OtherIsland foo="this prop will be serialized" />
</MyIsland>
<p>Some more server rendered text</p>
</div>
);
}
```

## Rendering islands on client only

When using client-only APIs, like `EventSource` or `navigator.getUserMedia`,
this component will not run on the server as it will produce an error like:

```
An error occurred during route handling or page rendering. ReferenceError: EventSource is not defined
at Object.MyIsland (file:///Users/someuser/fresh-project/islandsmy-island.tsx:6:18)
at m (https://esm.sh/v129/[email protected]/X-ZS8q/denonext/preact-render-to-string.mjs:2:2602)
at m (https://esm.sh/v129/[email protected]/X-ZS8q/denonext/preact-render-to-string.mjs:2:2113)
....
```

Use the [`IS_BROWSER`](https://deno.land/x/fresh/runtime.ts?doc=&s=IS_BROWSER)
flag as a guard to fix the issue:

```tsx islands/my-island.tsx
import { IS_BROWSER } from "$fresh/runtime.ts";

export function MyIsland() {
// Return any prerenderable JSX here which makes sense for your island
if (!IS_BROWSER) return <div></div>;

// All the code which must run in the browser comes here!
// Like: EventSource, navigator.getUserMedia, etc.
return <div></div>;
}
```
1 change: 1 addition & 0 deletions src/dev/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { existsSync } from "https://deno.land/[email protected]/fs/mod.ts";
export * as semver from "https://deno.land/[email protected]/semver/mod.ts";
export * as JSONC from "https://deno.land/[email protected]/jsonc/mod.ts";
export * as fs from "https://deno.land/[email protected]/fs/mod.ts";
export { isWindows } from "https://deno.land/[email protected]/_util/os.ts";

// ts-morph
export { Node, Project } from "https://deno.land/x/[email protected]/mod.ts";
73 changes: 67 additions & 6 deletions src/dev/mod.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { gte, join, posix, relative, walk, WalkEntry } from "./deps.ts";
import {
dirname,
gte,
isWindows,
join,
posix,
relative,
SEP,
walk,
WalkEntry,
} from "./deps.ts";
import { error } from "./error.ts";
const MIN_DENO_VERSION = "1.31.0";
const TEST_FILE_PATTERN = /[._]test\.(?:[tj]sx?|[mc][tj]s)$/;
const ISLAND_IMPORT_REGEX =
/import\s+(?:{[^}]+}\s+)?(?:[\w,]+\s+as\s+\w+\s+)?\w+\s+from\s+["']([^"']+)["']/;

export function ensureMinDenoVersion() {
// Check that the minimum supported Deno version is being used.
Expand Down Expand Up @@ -59,7 +71,7 @@ export async function collect(
const filePaths = new Set<string>();

const routes: string[] = [];
const islands: string[] = [];
const islandsSet: Set<string> = new Set();
await Promise.all([
collectDir(join(directory, "./routes"), (entry, dir) => {
const rel = join("routes", relative(dir, entry.path));
Expand All @@ -71,7 +83,7 @@ export async function collect(
const match = normalized.match(GROUP_REG);
if (match && match[1].startsWith("_")) {
if (match[1] === "_islands") {
islands.push(rel);
islandsSet.add(rel);
}
return;
}
Expand All @@ -84,22 +96,58 @@ export async function collect(
filePaths.add(normalized);
routes.push(rel);
}, ignoreFilePattern),
collectDir(join(directory, "./islands"), (entry, dir) => {
const rel = join("islands", relative(dir, entry.path));
islands.push(rel);
collectDir(join(directory, "./islands"), (entry) => {
islandsSet.add(getManifestItemFromPath(directory, entry.path));
}, ignoreFilePattern),
collectDir(directory, (entry, _) => {
const file = Deno.readTextFileSync(entry.path);
const lines = file.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.match(/\/\/+\s*@fresh-island\s*/)) {
const islandImportLine = lines[i + 1];
const matches = islandImportLine.match(ISLAND_IMPORT_REGEX);
if (matches) {
const match = matches[1];
if (isURL(match)) {
islandsSet.add(match);
} else {
const fileDir = dirname(entry.path);
const resolvedPath = join(fileDir, matches[1]);
const relativePath = relative(directory, resolvedPath);
islandsSet.add(getManifestItemFromPath(directory, relativePath));
}
}
}
}
}),
]);

routes.sort();
// Remove duplicate islands
const islands = [];
islands.push(...islandsSet);
islands.sort();

return { routes, islands };
}

function isURL(url: unknown): boolean {
try {
new URL(url as string);
return true;
} catch {
return false;
}
}

/**
* Import specifiers must have forward slashes
*/
function toImportSpecifier(file: string) {
if (isURL(file)) {
return file;
}
let specifier = posix.normalize(file).replace(/\\/g, "/");
if (!specifier.startsWith(".")) {
specifier = "./" + specifier;
Expand Down Expand Up @@ -176,3 +224,16 @@ export default manifest;
"color: blue; font-weight: bold",
);
}

function getManifestItemFromPath(directory: string, filePath: string) {
let relativePath = relative(directory, filePath);

if (!relativePath.startsWith(".") && !relativePath.startsWith(SEP)) {
relativePath = `.${SEP}${relativePath}`;
}

if (isWindows) {
relativePath = relativePath.replaceAll(SEP, posix.sep);
}
return relativePath;
}
21 changes: 11 additions & 10 deletions src/server/fs_extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,15 +235,13 @@ export async function extractRoutes(

for (const [self, module] of Object.entries(manifest.islands)) {
const url = new URL(self, baseUrl).href;
if (!url.startsWith(baseUrl)) {
throw new TypeError("Island is not a child of the basepath.");
}
let path = url.substring(baseUrl.length);
if (path.startsWith("islands")) {
path = path.slice("islands".length + 1);
let baseRoute = self.substring(0, self.length - extname(self).length);
if (self.startsWith("./islands/")) {
baseRoute = self.substring(
"./islands/".length,
self.length - extname(self).length,
);
}
const baseRoute = path.substring(0, path.length - extname(path).length);

for (const [exportName, exportedFunction] of Object.entries(module)) {
if (typeof exportedFunction !== "function") {
continue;
Expand Down Expand Up @@ -475,8 +473,11 @@ function toPascalCase(text: string): string {
}

function sanitizeIslandName(name: string): string {
const fileName = name.replaceAll(/[/\\\\\(\)\[\]]/g, "_");
return toPascalCase(fileName);
// Remove all non-alphanumeric characters to make a safe variable name
name = name.replaceAll(/[^a-zA-Z0-9_]/g, "_");
// Append $ if the variable name would start with a numeric
if (name.match(/^[0-9]/)) name = "$" + name;
return toPascalCase(name);
}

function formatMiddlewarePath(path: string): string {
Expand Down
14 changes: 14 additions & 0 deletions tests/fixture_island_hints/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"lock": false,
"imports": {
"$fresh/": "../../",
"preact": "https://esm.sh/[email protected]",
"preact/": "https://esm.sh/[email protected]/",
"@preact/signals": "https://esm.sh/*@preact/[email protected]",
"@preact/signals-core": "https://esm.sh/@preact/[email protected]"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
Loading